From a403b8b6af4704e5ed45152b512c985feb44d4af Mon Sep 17 00:00:00 2001 From: Divarion_D Date: Mon, 21 Sep 2026 18:37:33 +0300 Subject: [PATCH] feat(category-templates): apply templates on line/mag/enigma & reseller vouchers Add category-template selection to the line, mag and enigma edit forms and to the reseller active-code (voucher) form, plus a reseller navbar entry. Extract the shared custom_data resolver into CategoryTemplateService::applyCustomData() and route the Line/Mag/Enigma saves through it, removing the triplicated inline block; covered by a new unit test. Review fixes folded in: - getRecentBatchNames filtered a non-existent `user_id` column and used DISTINCT + ORDER BY id (rejected by MySQL ONLY_FULL_GROUP_BY). Restore the correct created_by + GROUP BY MAX(created_at) query (kept the empty-batch guard). - routes/admin.php: import ActiveCodeDetailsController (fatal short-name use without it) for the active_code_details route. - Revert the accidental re-inline of the ancestorOwnerIds() helper in getTemplatesForUser and the getResellersWithCodes all-users fallback. Also: settings save only zeroes checkbox flags on the full settings form (submit_settings), and reseller bootstrap tolerates a missing language. --- src/Domain/Device/EnigmaService.php | 2 + src/Domain/Device/MagService.php | 13 +-- src/Domain/Line/ActiveCodeService.php | 2 +- src/Domain/Line/LineService.php | 13 +-- src/Domain/Server/SettingsService.php | 5 +- src/Domain/Stream/CategoryTemplateService.php | 46 ++++++++++ .../Bootstrap/ResellerScopeBootstrap.php | 5 +- .../Admin/ActiveCodeDetailsController.php | 6 +- .../Controllers/Admin/EnigmaController.php | 7 +- src/Public/Views/admin/enigma.php | 72 ++++++++++++++- src/Public/Views/admin/line.php | 76 +++++++++++++++- src/Public/Views/admin/mag.php | 72 ++++++++++++++- src/Public/Views/layouts/reseller/header.php | 7 ++ src/Public/Views/reseller/active_code.php | 29 +++++++ src/Public/routes/admin.php | 3 +- .../CategoryTemplateApplyCustomDataTest.php | 87 +++++++++++++++++++ 16 files changed, 410 insertions(+), 35 deletions(-) create mode 100644 tests/Unit/CategoryTemplateApplyCustomDataTest.php diff --git a/src/Domain/Device/EnigmaService.php b/src/Domain/Device/EnigmaService.php index a47b0a6ff..ce8d37da4 100644 --- a/src/Domain/Device/EnigmaService.php +++ b/src/Domain/Device/EnigmaService.php @@ -10,6 +10,7 @@ use XcVm\Domain\Bouquet\BouquetService; use XcVm\Domain\Line\LineRepository; use XcVm\Domain\Line\LineService; use XcVm\Domain\User\UserRepository; +use XcVm\Domain\Stream\CategoryTemplateService; use XcVm\Infrastructure\Database\DatabaseAware; /** @@ -289,6 +290,7 @@ class EnigmaService { } if (0 >= $db->num_rows()) { + $rUserArray = CategoryTemplateService::applyCustomData($rData, $rUserArray); $rPrepare = QueryHelper::prepareArray($rUserArray); $rQuery = 'REPLACE INTO `lines`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; diff --git a/src/Domain/Device/MagService.php b/src/Domain/Device/MagService.php index 18322b38e..8837f61d2 100644 --- a/src/Domain/Device/MagService.php +++ b/src/Domain/Device/MagService.php @@ -342,18 +342,7 @@ class MagService { } if (0 >= $db->num_rows()) { - if (isset($rData['category_template_id'])) { - if ($rData['category_template_id'] === '0' || $rData['category_template_id'] === 'none') { - $rUserArray['custom_data'] = null; - } elseif (intval($rData['category_template_id']) > 0) { - $customDataObj = \XcVm\Domain\Stream\CategoryTemplateService::buildCustomData(intval($rData['category_template_id'])); - $rUserArray['custom_data'] = json_encode($customDataObj, JSON_UNESCAPED_UNICODE); - } - } elseif (isset($rData['custom_data'])) { - $rUserArray['custom_data'] = ((string) $rData['custom_data'] !== '') - ? (is_array($rData['custom_data']) ? json_encode($rData['custom_data'], JSON_UNESCAPED_UNICODE) : $rData['custom_data']) - : null; - } + $rUserArray = \XcVm\Domain\Stream\CategoryTemplateService::applyCustomData($rData, $rUserArray); $rPrepare = QueryHelper::prepareArray($rUserArray); diff --git a/src/Domain/Line/ActiveCodeService.php b/src/Domain/Line/ActiveCodeService.php index 53914c2ac..d81973f92 100644 --- a/src/Domain/Line/ActiveCodeService.php +++ b/src/Domain/Line/ActiveCodeService.php @@ -573,7 +573,7 @@ class ActiveCodeService { */ public static function getRecentBatchNames(array $createdBy = [], int $limit = 100): array { $db = self::db(); - $where = '`batch_name` IS NOT NULL'; + $where = '`batch_name` IS NOT NULL AND `batch_name` != ""'; if ($createdBy !== []) { $where = '`created_by` IN (' . implode(',', array_map('intval', $createdBy)) . ') AND ' . $where; } diff --git a/src/Domain/Line/LineService.php b/src/Domain/Line/LineService.php index 4798ac6af..1a0471dae 100644 --- a/src/Domain/Line/LineService.php +++ b/src/Domain/Line/LineService.php @@ -250,18 +250,7 @@ class LineService { $rArray['allowed_outputs'] = '[' . implode(',', array_map('intval', $rOutputs)) . ']'; - if (isset($rData['category_template_id'])) { - if ($rData['category_template_id'] === '0' || $rData['category_template_id'] === 'none') { - $rArray['custom_data'] = null; - } elseif (intval($rData['category_template_id']) > 0) { - $customDataObj = \XcVm\Domain\Stream\CategoryTemplateService::buildCustomData(intval($rData['category_template_id'])); - $rArray['custom_data'] = json_encode($customDataObj, JSON_UNESCAPED_UNICODE); - } - } elseif (isset($rData['custom_data'])) { - $rArray['custom_data'] = ((string) $rData['custom_data'] !== '') - ? (is_array($rData['custom_data']) ? json_encode($rData['custom_data'], JSON_UNESCAPED_UNICODE) : $rData['custom_data']) - : null; - } + $rArray = \XcVm\Domain\Stream\CategoryTemplateService::applyCustomData($rData, $rArray); $rEditID = (isset($rData['edit']) ? intval($rData['edit']) : null); diff --git a/src/Domain/Server/SettingsService.php b/src/Domain/Server/SettingsService.php index c8a19371e..fe7d6bcad 100644 --- a/src/Domain/Server/SettingsService.php +++ b/src/Domain/Server/SettingsService.php @@ -54,10 +54,11 @@ class SettingsService { $rArray = QueryHelper::verifyPostTable('settings', $rData, true); + $isFullForm = isset($rData['submit_settings']); foreach (['php_loopback', 'restreamer_bypass_proxy', 'request_prebuffer', 'modal_edit', 'group_buttons', 'enable_search', 'on_demand_checker', 'ondemand_balance_equal', 'disable_mag_token', 'allow_cdn_access', 'dts_legacy_ffmpeg', 'mag_load_all_channels', 'disable_xmltv_restreamer', 'disable_playlist_restreamer', 'ffmpeg_warnings', 'reseller_ssl_domain', 'extract_subtitles', 'show_category_duplicates', 'vod_sort_newest', 'header_stats', 'mag_keep_extension', 'keep_protocol', 'read_native_hls', 'player_allow_playlist', 'player_allow_bouquet', 'player_hide_incompatible', 'player_allow_hevc', 'force_epg_timezone', 'check_vod', 'ignore_keyframes', 'save_login_logs', 'save_restart_logs', 'mag_legacy_redirect', 'restrict_playlists', 'monitor_connection_status', 'kill_rogue_ffmpeg', 'show_images', 'on_demand_instant_off', 'on_demand_failure_exit', 'playlist_from_mysql', 'ignore_invalid_users', 'legacy_mag_auth', 'ministra_allow_blank', 'block_proxies', 'block_streaming_servers', 'ip_subnet_match', 'auto_unban_ip', 'debug_show_errors', 'enable_debug_stalker', 'restart_php_fpm', 'restream_deny_unauthorised', 'api_probe', 'legacy_panel_api', 'hide_failures', 'verify_host', 'encrypt_playlist', 'encrypt_playlist_restreamer', 'mag_disable_ssl', 'legacy_get', 'legacy_xmltv', 'save_closed_connection', 'show_tickets', 'stream_logs_save', 'client_logs_save', 'streams_grouped', 'cloudflare', 'cleanup', 'dashboard_stats', 'dashboard_status', 'dashboard_map', 'dashboard_display_alt', 'recaptcha_enable', 'ip_logout', 'disable_player_api', 'disable_playlist', 'disable_xmltv', 'disable_enigma2', 'disable_ministra', 'enable_isp_lock', 'block_svp', 'disable_ts', 'disable_ts_allow_restream', 'disable_hls', 'disable_hls_allow_restream', 'disable_rtmp', 'disable_rtmp_allow_restream', 'case_sensitive_line', 'county_override_1st', 'disallow_2nd_ip_con', 'use_mdomain_in_lists', 'encrypt_hls', 'disallow_empty_user_agents', 'detect_restream_block_user', 'download_images', 'api_redirect', 'use_buffer', 'audio_restart_loss', 'show_isps', 'priority_backup', 'rtmp_random', 'show_connected_video', 'show_not_on_air_video', 'show_banned_video', 'show_expired_video', 'show_expiring_video', 'show_all_category_mag', 'always_enabled_subtitles', 'enable_connection_problem_indication', 'show_tv_channel_logo', 'show_channel_logo_in_preview', 'disable_trial', 'restrict_same_ip', 'fanout_source_insecure', 'fanout_supervise', 'secure_stream_tokens', 'js_navigate'] as $rSetting) { if (isset($rData[$rSetting])) { $rArray[$rSetting] = 1; - } else { + } elseif ($isFullForm) { $rArray[$rSetting] = 0; } } @@ -104,7 +105,7 @@ class SettingsService { $rArray['search_items'] = 1; } - if (isset($rArray['language']) && class_exists(Translator::class, false)) { + if (isset($rArray['language'])) { if (!in_array($rArray['language'], Translator::available(), true)) { $rArray['language'] = 'en'; } diff --git a/src/Domain/Stream/CategoryTemplateService.php b/src/Domain/Stream/CategoryTemplateService.php index 03159d05f..bb0880473 100644 --- a/src/Domain/Stream/CategoryTemplateService.php +++ b/src/Domain/Stream/CategoryTemplateService.php @@ -745,6 +745,52 @@ class CategoryTemplateService { return $customData; } + /** + * Resolve the `custom_data` column on a line/device payload from an incoming + * form. Shared by line, mag and enigma saves. The column is only touched when + * the request carries `category_template_id` or `custom_data`, so an untouched + * edit preserves the stored layout. + * + * @param array $rData Incoming request data. + * @param array $target Line/device row being built. + * @return array The row with `custom_data` applied. + */ + public static function applyCustomData(array $rData, array $target): array { + if (isset($rData['category_template_id'])) { + return self::applyTemplateId($rData['category_template_id'], $target); + } + if (isset($rData['custom_data'])) { + $target['custom_data'] = self::normaliseCustomData($rData['custom_data']); + } + return $target; + } + + /** + * Apply a selected template id: "0"/"none" clears the layout, a positive id + * builds it, anything else leaves the row unchanged. + * + * @param array $target + * @return array + */ + private static function applyTemplateId(mixed $templateId, array $target): array { + if ($templateId === '0' || $templateId === 'none') { + $target['custom_data'] = null; + } elseif (intval($templateId) > 0) { + $target['custom_data'] = json_encode(self::buildCustomData(intval($templateId)), JSON_UNESCAPED_UNICODE); + } + return $target; + } + + /** + * Normalise a raw `custom_data` payload to the stored string form (or null). + */ + private static function normaliseCustomData(mixed $customData): ?string { + if (is_array($customData)) { + return json_encode($customData, JSON_UNESCAPED_UNICODE); + } + return (string) $customData === '' ? null : (string) $customData; + } + /** * Count active subscriber lines attached to a specific template. */ diff --git a/src/Infrastructure/Bootstrap/ResellerScopeBootstrap.php b/src/Infrastructure/Bootstrap/ResellerScopeBootstrap.php index e7393153d..05a44d852 100644 --- a/src/Infrastructure/Bootstrap/ResellerScopeBootstrap.php +++ b/src/Infrastructure/Bootstrap/ResellerScopeBootstrap.php @@ -46,7 +46,8 @@ final class ResellerScopeBootstrap implements ScopeBootstrap { } // Expire session after timeout - if (isset($_SESSION['reseller'], $_SESSION['rlast_activity']) + if ( + isset($_SESSION['reseller'], $_SESSION['rlast_activity']) && ($rSessionTimeout * 60) < (time() - $_SESSION['rlast_activity']) ) { foreach (['reseller', 'rip', 'rcode', 'rverify', 'rlast_activity'] as $rKey) { @@ -99,7 +100,7 @@ final class ResellerScopeBootstrap implements ScopeBootstrap { setcookie('hue', $rUserInfo['hue'] ?? '', time() + 604800); setcookie('theme', $rUserInfo['theme'] ?? '', time() + 604800); - Translator::setLanguage($rUserInfo['lang']); + Translator::setLanguage($rUserInfo['lang'] ?? 'en'); $rPermissions = array_merge(AuthRepository::getPermissions($rUserInfo['member_group_id']), AuthRepository::getGroupPermissions($rUserInfo['id'])); $rPermissions['direct_reports'] = $rPermissions['direct_reports'] ?? []; diff --git a/src/Public/Controllers/Admin/ActiveCodeDetailsController.php b/src/Public/Controllers/Admin/ActiveCodeDetailsController.php index e3aa521af..edbea4786 100644 --- a/src/Public/Controllers/Admin/ActiveCodeDetailsController.php +++ b/src/Public/Controllers/Admin/ActiveCodeDetailsController.php @@ -18,17 +18,21 @@ use XcVm\Domain\Line\PackageService; * @package XC_VM_Public_Controllers_Admin */ class ActiveCodeDetailsController { + use \XcVm\Infrastructure\Database\DatabaseAware; + + /** * action=active_code_details — full voucher & companion line details. */ public function index(): never { if ((!defined('PHP_ERRORS') || !PHP_ERRORS) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') !== 'xmlhttprequest' + && !RequestManager::has('id') ) { exit(); } - global $db; + $db = self::db(); $codeId = (int) (RequestManager::get('id') ?? 0); $code = $codeId ? $db->fetchOne( diff --git a/src/Public/Controllers/Admin/EnigmaController.php b/src/Public/Controllers/Admin/EnigmaController.php index 1fdc9b984..425c8fd5f 100644 --- a/src/Public/Controllers/Admin/EnigmaController.php +++ b/src/Public/Controllers/Admin/EnigmaController.php @@ -33,7 +33,12 @@ class EnigmaController extends BaseAdminController { $rDevice['user'] = ['bouquet' => []]; } + $categoryTemplates = \XcVm\Domain\Stream\CategoryTemplateService::getTemplatesForUser( + $GLOBALS['rAdminUserInfo'] ?? ($GLOBALS['rUserInfo'] ?? []), + true + ); + $this->setTitle('Enigma Device'); - $this->render('enigma', ['rDevice' => $rDevice]); + $this->render('enigma', ['rDevice' => $rDevice, 'categoryTemplates' => $categoryTemplates]); } } diff --git a/src/Public/Views/admin/enigma.php b/src/Public/Views/admin/enigma.php index 7b45d62d3..1c843b3f1 100644 --- a/src/Public/Views/admin/enigma.php +++ b/src/Public/Views/admin/enigma.php @@ -17,6 +17,7 @@ use XcVm\Core\Reference\GeoReference; use XcVm\Domain\Bouquet\BouquetService; use XcVm\Domain\User\UserRepository; +use XcVm\Domain\Stream\CategoryTemplateService; $rIsEdit = isset($rDevice['device_id']); $rUser = $rDevice['user'] ?? []; @@ -28,6 +29,26 @@ $rDeviceIPs = (isset($rUser['allowed_ips']) && $rUser['allowed_ips'] !== '') ? (json_decode((string) $rUser['allowed_ips'], true) ?: []) : []; $rOwnerRow = (isset($rUser['member_id']) && ($rTmp = UserRepository::getRegisteredUserById((int) $rUser['member_id']))) ? $rTmp : null; + +$categoryTemplates = $categoryTemplates ?? (class_exists(CategoryTemplateService::class) ? CategoryTemplateService::getTemplatesForUser( + $GLOBALS['rAdminUserInfo'] ?? ($GLOBALS['rUserInfo'] ?? []), + true +) : []); + +$rCurrentTemplateId = null; +$rCurrentTemplateName = null; +if ($rIsEdit && !empty($rUser['custom_data'])) { + $rCustomDataArr = is_array($rUser['custom_data']) ? $rUser['custom_data'] : json_decode((string) $rUser['custom_data'], true); + if (!empty($rCustomDataArr['template_id'])) { + $rCurrentTemplateId = (int) $rCustomDataArr['template_id']; + foreach ($categoryTemplates as $tpl) { + if ((int) ($tpl['id'] ?? 0) === $rCurrentTemplateId) { + $rCurrentTemplateName = $tpl['name'] ?? $tpl['template_name'] ?? null; + break; + } + } + } +} ?> @@ -239,6 +260,39 @@ $rOwnerRow = (isset($rUser['member_id']) && ($rTmp = UserRepository::getRegis
+ +
+
+
+ + + + + + +
+ +
+
+
@@ -349,6 +403,22 @@ renderUnifiedLayoutFooter('admin'); } } }); + $('#category_template_id').select2({ + width: '100%', + dropdownParent: $('#category_template_id').closest('.tab-pane') + }); + } + + // Re-align select2 width when switching to bouquets tab + if ($) { + $('button[data-bs-toggle="tab"][data-bs-target="#tab-bouquets"]').on('shown.bs.tab', function() { + if ($.fn.select2) { + $('#category_template_id').select2({ + width: '100%', + dropdownParent: $('#category_template_id').closest('.tab-pane') + }); + } + }); } var pairEl = document.getElementById('pair_id'); @@ -358,7 +428,7 @@ renderUnifiedLayoutFooter('admin'); // Pairing a user takes over the owner/expiry/advanced/bouquet inputs, so hide // and disable them while paired (legacy evaluatePair()). - var pairedFields = ['exp_date', 'is_trial', 'no_expire', 'is_isplock', 'force_server_id', 'forced_country', 'ip_field', 'allowed_ips', 'member_id']; + var pairedFields = ['exp_date', 'is_trial', 'no_expire', 'is_isplock', 'force_server_id', 'forced_country', 'ip_field', 'allowed_ips', 'member_id', 'category_template_id']; var applyPair = function() { var paired = isPaired(); document.getElementById('linked_info').classList.toggle('d-none', paired); diff --git a/src/Public/Views/admin/line.php b/src/Public/Views/admin/line.php index bd8b5d237..4253682b3 100644 --- a/src/Public/Views/admin/line.php +++ b/src/Public/Views/admin/line.php @@ -18,6 +18,7 @@ use XcVm\Core\Reference\GeoReference; use XcVm\Domain\Bouquet\BouquetService; use XcVm\Domain\Line\LineRepository; +use XcVm\Domain\Stream\CategoryTemplateService; $rIsEdit = isset($rLine); $rLineBouquets = ($rIsEdit && !empty($rLine['bouquet'])) ? (json_decode((string) $rLine['bouquet'], true) ?: []) : []; @@ -25,6 +26,26 @@ $rLineOutputs = ($rIsEdit && !empty($rLine['allowed_outputs'])) ? (json_decod $rLineIPs = ($rIsEdit && !empty($rLine['allowed_ips'])) ? (json_decode((string) $rLine['allowed_ips'], true) ?: []) : []; $rLineUAs = ($rIsEdit && !empty($rLine['allowed_ua'])) ? (json_decode((string) $rLine['allowed_ua'], true) ?: []) : []; $rOwnerID = $rIsEdit ? (int) $rLine['member_id'] : (int) ($rUserInfo['id'] ?? 0); + +$categoryTemplates = $categoryTemplates ?? (class_exists(CategoryTemplateService::class) ? CategoryTemplateService::getTemplatesForUser( + $GLOBALS['rAdminUserInfo'] ?? ($GLOBALS['rUserInfo'] ?? []), + true +) : []); + +$rCurrentTemplateId = null; +$rCurrentTemplateName = null; +if ($rIsEdit && !empty($rLine['custom_data'])) { + $rCustomDataArr = is_array($rLine['custom_data']) ? $rLine['custom_data'] : json_decode((string) $rLine['custom_data'], true); + if (!empty($rCustomDataArr['template_id'])) { + $rCurrentTemplateId = (int) $rCustomDataArr['template_id']; + foreach ($categoryTemplates as $tpl) { + if ((int) ($tpl['id'] ?? 0) === $rCurrentTemplateId) { + $rCurrentTemplateName = $tpl['name'] ?? $tpl['template_name'] ?? null; + break; + } + } + } +} ?> @@ -227,7 +248,44 @@ $rOwnerID = $rIsEdit ? (int) $rLine['member_id'] : (int) ($rUserInfo['id'
-
+ +
+
+
+ + + + + + +
+ +
+
+
+ +
+
@@ -297,6 +355,22 @@ renderUnifiedLayoutFooter('admin'); width: '100%', dropdownParent: $('#forced_country').closest('.tab-pane') }); + $('#category_template_id').select2({ + width: '100%', + dropdownParent: $('#category_template_id').closest('.tab-pane') + }); + } + + // Re-align select2 width when switching to bouquets tab + if ($) { + $('button[data-bs-toggle="tab"][data-bs-target="#tab-bouquets"]').on('shown.bs.tab', function() { + if ($.fn.select2) { + $('#category_template_id').select2({ + width: '100%', + dropdownParent: $('#category_template_id').closest('.tab-pane') + }); + } + }); } // Numeric-only guard mirroring the legacy inputFilter (/^\d*$/). diff --git a/src/Public/Views/admin/mag.php b/src/Public/Views/admin/mag.php index 869eb5224..ddcc5805f 100644 --- a/src/Public/Views/admin/mag.php +++ b/src/Public/Views/admin/mag.php @@ -17,6 +17,7 @@ use XcVm\Core\Reference\GeoReference; use XcVm\Domain\Bouquet\BouquetService; use XcVm\Domain\User\UserRepository; +use XcVm\Domain\Stream\CategoryTemplateService; $rIsEdit = isset($rDevice['mag_id']); $rUser = $rDevice['user'] ?? []; @@ -28,6 +29,26 @@ $rDeviceIPs = (isset($rUser['allowed_ips']) && $rUser['allowed_ips'] !== '') ? (json_decode((string) $rUser['allowed_ips'], true) ?: []) : []; $rOwnerRow = (isset($rUser['member_id']) && ($rTmp = UserRepository::getRegisteredUserById((int) $rUser['member_id']))) ? $rTmp : null; + +$categoryTemplates = $categoryTemplates ?? (class_exists(CategoryTemplateService::class) ? CategoryTemplateService::getTemplatesForUser( + $GLOBALS['rAdminUserInfo'] ?? ($GLOBALS['rUserInfo'] ?? []), + true +) : []); + +$rCurrentTemplateId = null; +$rCurrentTemplateName = null; +if ($rIsEdit && !empty($rUser['custom_data'])) { + $rCustomDataArr = is_array($rUser['custom_data']) ? $rUser['custom_data'] : json_decode((string) $rUser['custom_data'], true); + if (!empty($rCustomDataArr['template_id'])) { + $rCurrentTemplateId = (int) $rCustomDataArr['template_id']; + foreach ($categoryTemplates as $tpl) { + if ((int) ($tpl['id'] ?? 0) === $rCurrentTemplateId) { + $rCurrentTemplateName = $tpl['name'] ?? $tpl['template_name'] ?? null; + break; + } + } + } +} ?> @@ -252,6 +273,39 @@ $rOwnerRow = (isset($rUser['member_id']) && ($rTmp = UserRepository::getRegis
+ +
+
+
+ + + + + + +
+ +
+
+
@@ -361,6 +415,22 @@ renderUnifiedLayoutFooter('admin'); } } }); + $('#category_template_id').select2({ + width: '100%', + dropdownParent: $('#category_template_id').closest('.tab-pane') + }); + } + + // Re-align select2 width when switching to bouquets tab + if ($) { + $('button[data-bs-toggle="tab"][data-bs-target="#tab-bouquets"]').on('shown.bs.tab', function() { + if ($.fn.select2) { + $('#category_template_id').select2({ + width: '100%', + dropdownParent: $('#category_template_id').closest('.tab-pane') + }); + } + }); } var pairEl = document.getElementById('pair_id'); @@ -370,7 +440,7 @@ renderUnifiedLayoutFooter('admin'); // Pairing a user takes over the owner/expiry/advanced/bouquet inputs, so hide // and disable them while paired (legacy evaluatePair()). - var pairedFields = ['exp_date', 'is_trial', 'no_expire', 'is_isplock', 'force_server_id', 'forced_country', 'ip_field', 'allowed_ips', 'member_id']; + var pairedFields = ['exp_date', 'is_trial', 'no_expire', 'is_isplock', 'force_server_id', 'forced_country', 'ip_field', 'allowed_ips', 'member_id', 'category_template_id']; var applyPair = function() { var paired = isPaired(); document.getElementById('linked_info').classList.toggle('d-none', paired); diff --git a/src/Public/Views/layouts/reseller/header.php b/src/Public/Views/layouts/reseller/header.php index 24e0abe4b..c0d5e5829 100644 --- a/src/Public/Views/layouts/reseller/header.php +++ b/src/Public/Views/layouts/reseller/header.php @@ -111,6 +111,12 @@ $xmMenu = [ ['label' => 'batch_manager', 'url' => 'active_codes_batch', 'show' => true], ], ], + [ + 'label' => 'category_templates', + 'icon' => 'ti tabler-layout-grid', + 'url' => 'category_templates', + 'show' => true, + ], [ 'label' => 'content', 'icon' => 'ti tabler-player-play', @@ -123,6 +129,7 @@ $xmMenu = [ ['label' => 'episodes', 'url' => 'episodes', 'show' => true], ['label' => 'radios', 'url' => 'radios', 'show' => true], ['label' => 'tv_guide', 'url' => 'epg_view', 'show' => !$rMobile], + ['label' => 'category_templates', 'url' => 'category_templates', 'show' => true], ], ], [ diff --git a/src/Public/Views/reseller/active_code.php b/src/Public/Views/reseller/active_code.php index ed3ecd370..32484d326 100644 --- a/src/Public/Views/reseller/active_code.php +++ b/src/Public/Views/reseller/active_code.php @@ -166,6 +166,35 @@ $dnsList = array_filter(array_map('trim', explode(',', (string)($rUserInfo['rese
+ + +
+ + +
+
+ +
diff --git a/src/Public/routes/admin.php b/src/Public/routes/admin.php index dabad9453..2927cb866 100644 --- a/src/Public/routes/admin.php +++ b/src/Public/routes/admin.php @@ -1,6 +1,7 @@ api('get_package', [PackageAjaxController::class, 'getPackage']); $router->api('get_package_trial', [PackageAjaxController::class, 'getPackageTrial']); // ─── Active Codes ────────────────────────────────── -$router->api('active_code_details', [ActiveCodeAjaxController::class, 'details']); +$router->api('active_code_details', [ActiveCodeDetailsController::class, 'index']); $router->api('active_code_edit', [ActiveCodeAjaxController::class, 'edit']); $router->api('active_code_delete', [ActiveCodeAjaxController::class, 'delete']); $router->api('generate_active_codes', [ActiveCodeAjaxController::class, 'generate']); diff --git a/tests/Unit/CategoryTemplateApplyCustomDataTest.php b/tests/Unit/CategoryTemplateApplyCustomDataTest.php new file mode 100644 index 000000000..8ddfe7e51 --- /dev/null +++ b/tests/Unit/CategoryTemplateApplyCustomDataTest.php @@ -0,0 +1,87 @@ +db = new TestDb(); + $this->db->exec( + 'CREATE TABLE `category_template_items` ( + `id` INTEGER PRIMARY KEY, + `template_id` INTEGER, + `category_type` TEXT, + `category_id` INTEGER, + `custom_name` TEXT, + `is_visible` INTEGER DEFAULT 1, + `sort_order` INTEGER DEFAULT 0 + ); + INSERT INTO `category_template_items` + (`id`,`template_id`,`category_type`,`category_id`,`custom_name`,`is_visible`,`sort_order`) VALUES + (1,5,"live",10,"Renamed",1,1), + (2,5,"live",11,NULL,0,2);' + ); + DatabaseFactory::set($this->db); + } + + public function testTemplateIdZeroOrNoneClearsCustomData(): void { + $this->assertNull( + CategoryTemplateService::applyCustomData(['category_template_id' => '0'], [])['custom_data'] + ); + $this->assertNull( + CategoryTemplateService::applyCustomData(['category_template_id' => 'none'], [])['custom_data'] + ); + } + + public function testPositiveTemplateIdBuildsCustomDataJson(): void { + $row = CategoryTemplateService::applyCustomData(['category_template_id' => '5'], []); + + $this->assertArrayHasKey('custom_data', $row); + $decoded = json_decode((string) $row['custom_data'], true); + $this->assertSame(5, $decoded['template_id']); + // Hidden category (is_visible=0) lands in the live hide list. + $this->assertStringContainsString('11', (string) $decoded['live_cat']['hide_ids']); + $this->assertSame('10,11', $decoded['live_cat']['order']); + } + + public function testInvalidTemplateIdLeavesCustomDataUntouched(): void { + // Empty / non-numeric template id must not overwrite an existing layout. + $row = CategoryTemplateService::applyCustomData( + ['category_template_id' => ''], + ['custom_data' => 'KEEP'] + ); + $this->assertSame('KEEP', $row['custom_data']); + } + + public function testRawCustomDataStringAndArrayAndEmpty(): void { + $this->assertSame( + '{"x":1}', + CategoryTemplateService::applyCustomData(['custom_data' => '{"x":1}'], [])['custom_data'] + ); + $this->assertSame( + '{"x":1}', + CategoryTemplateService::applyCustomData(['custom_data' => ['x' => 1]], [])['custom_data'] + ); + $this->assertNull( + CategoryTemplateService::applyCustomData(['custom_data' => ''], [])['custom_data'] + ); + } + + public function testNeitherKeyPreservesTarget(): void { + $target = ['username' => 'bob', 'custom_data' => 'EXISTING']; + $this->assertSame($target, CategoryTemplateService::applyCustomData([], $target)); + } +}