39481-vm/app/channel_data.php
Flatlogic Bot cc09913585 v2
2026-04-05 11:27:52 +00:00

1405 lines
51 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/../db/config.php';
const CHANNEL_EDITABLE_FIELDS = [
'upper_ch_ref',
'country_iso',
'sat_ref',
'genre',
'type',
'groupe',
'region',
'langue',
'resolution',
'active',
'sat_in',
'sat_out',
'sat_update',
];
const CHANNEL_LOCKED_FIELDS = [
'country_client',
'sat_tpinfo',
'idtype',
'six_id',
'sid',
'onid',
'tid',
'sat_ch_client_upper',
'sat_client',
'counting',
'lastview',
'manually_edited',
'manually_edited_at',
];
const CHANNEL_LIST_COLUMNS = [
'id', 'upper_ch_ref', 'country_iso', 'country_client', 'sat_ref', 'sat_tpinfo', 'genre', 'type', 'resolution',
'langue', 'region', 'groupe', 'active', 'idtype', 'six_id', 'sid', 'onid', 'tid', 'sat_in', 'sat_out',
'sat_update', 'sat_ch_client_upper', 'sat_client', 'counting', 'lastview', 'manually_edited', 'manually_edited_at'
];
const CHANNEL_RECORD_COLUMNS = [
'upper_ch_ref', 'country_iso', 'country_client', 'sat_ref', 'sat_tpinfo', 'genre', 'type', 'resolution',
'langue', 'region', 'groupe', 'active', 'idtype', 'six_id', 'sid', 'onid', 'tid', 'sat_in', 'sat_out',
'sat_update', 'sat_ch_client_upper', 'sat_client', 'counting', 'lastview', 'manually_edited', 'manually_edited_at',
'date_in', 'date_out'
];
const CHANNEL_FILTER_OPTION_FIELDS = [
'sat_ref', 'country_iso', 'country_client', 'genre', 'type', 'resolution', 'langue', 'region', 'groupe',
'sat_tpinfo', 'sat_ch_client_upper', 'sat_client'
];
const CHANNEL_ADVANCED_FILTER_FIELDS = [
'upper_ch_ref', 'country_iso', 'country_client', 'sat_ref', 'sat_tpinfo', 'genre', 'type', 'resolution',
'langue', 'region', 'groupe', 'active', 'idtype', 'six_id', 'sid', 'onid', 'tid', 'sat_in', 'sat_out',
'sat_update', 'sat_ch_client_upper', 'sat_client', 'counting', 'lastview', 'manually_edited', 'manually_edited_at'
];
function channel_workspace_csv_candidates(): array
{
$root = dirname(__DIR__);
$preferred = glob($root . '/data-*.csv') ?: [];
$all = glob($root . '/*.csv') ?: [];
$files = array_values(array_unique(array_merge($preferred, $all)));
usort($files, static function (string $a, string $b): int {
$aPriority = basename($a) === 'data-1775387350803.csv' ? 0 : (str_starts_with(basename($a), 'data-') ? 1 : 2);
$bPriority = basename($b) === 'data-1775387350803.csv' ? 0 : (str_starts_with(basename($b), 'data-') ? 1 : 2);
if ($aPriority !== $bPriority) {
return $aPriority <=> $bPriority;
}
$mtimeCompare = @filemtime($b) <=> @filemtime($a);
if ($mtimeCompare !== 0) {
return $mtimeCompare;
}
return strcmp(basename($a), basename($b));
});
return $files;
}
function channel_detect_workspace_csv(): ?array
{
$files = channel_workspace_csv_candidates();
if (!$files) {
return null;
}
$path = $files[0];
return [
'path' => $path,
'basename' => basename($path),
'sha1' => is_file($path) ? sha1_file($path) : null,
'modified_at' => is_file($path) ? gmdate('Y-m-d H:i:s', (int) filemtime($path)) : null,
];
}
function channel_normalize_csv_header(string $header): string
{
$header = preg_replace('/^\xEF\xBB\xBF/', '', $header) ?? $header;
$header = strtolower(trim($header));
$header = preg_replace('/[^a-z0-9]+/', '_', $header) ?? $header;
return trim($header, '_');
}
function channel_csv_header_map(array $headers): array
{
$aliases = [
'upper_ch_ref' => ['upper_ch_ref', 'upper ch ref', 'upper channel ref', 'channel', 'channel_name', 'channel name', 'service_name', 'service name', 'name'],
'country_iso' => ['country_iso', 'country iso', 'country_code', 'country code', 'iso', 'iso2', 'country'],
'country_client' => ['country_client', 'country client', 'client_country', 'client country'],
'sat_ref' => ['sat_ref', 'sat ref', 'satellite', 'satellite_ref', 'satellite ref', 'satellite_reference'],
'sat_tpinfo' => ['sat_tpinfo', 'sat tpinfo', 'tpinfo', 'tp_info', 'transponder', 'frequency', 'freq'],
'genre' => ['genre', 'category'],
'type' => ['type', 'channel_type', 'channel type', 'free_pay', 'free/pay', 'fta_type'],
'resolution' => ['resolution', 'quality', 'video_quality', 'video quality', 'format'],
'langue' => ['langue', 'language', 'lang'],
'region' => ['region', 'market'],
'groupe' => ['groupe', 'group', 'bouquet'],
'active' => ['active', 'is_active', 'is active', 'enabled', 'status'],
'idtype' => ['idtype', 'id_type', 'id type', 'identifier_type', 'identifier type'],
'six_id' => ['six_id', 'six id', 'sixid'],
'sid' => ['sid', 'service_id', 'service id'],
'onid' => ['onid', 'original_network_id', 'original network id'],
'tid' => ['tid', 'transport_stream_id', 'transport stream id'],
'sat_in' => ['sat_in', 'sat in', 'start_date', 'start date'],
'sat_out' => ['sat_out', 'sat out', 'end_date', 'end date'],
'sat_update' => ['sat_update', 'sat update', 'update_date', 'update date', 'updated_at_source', 'updated at source', 'last_update', 'last update'],
'sat_ch_client_upper' => ['sat_ch_client_upper', 'sat ch client upper', 'client_channel_name', 'client channel name'],
'sat_client' => ['sat_client', 'sat client', 'client', 'platform', 'operator'],
'counting' => ['counting', 'hits', 'views', 'count'],
'lastview' => ['lastview', 'last view', 'last_seen', 'last seen', 'last_viewed_at', 'last viewed at'],
'manually_edited' => ['manually_edited', 'manually edited', 'manual_edit', 'manual edit'],
'manually_edited_at' => ['manually_edited_at', 'manually edited at', 'manual_edit_date', 'manual edit date'],
'date_in' => ['date_in', 'date in', 'record_date_in', 'record date in'],
'date_out' => ['date_out', 'date out', 'record_date_out', 'record date out'],
];
$normalizedAliases = [];
foreach ($aliases as $canonical => $variants) {
$normalizedAliases[channel_normalize_csv_header($canonical)] = $canonical;
foreach ($variants as $variant) {
$normalizedAliases[channel_normalize_csv_header($variant)] = $canonical;
}
}
$mapped = [];
foreach ($headers as $index => $header) {
$normalized = channel_normalize_csv_header((string) $header);
if ($normalized === '') {
continue;
}
$canonical = $normalizedAliases[$normalized] ?? null;
if ($canonical === null && in_array($normalized, CHANNEL_RECORD_COLUMNS, true)) {
$canonical = $normalized;
}
if ($canonical !== null && !isset($mapped[$canonical])) {
$mapped[$canonical] = (int) $index;
}
}
return $mapped;
}
function channel_csv_nullish(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = trim($value);
if ($value === '') {
return null;
}
$lower = strtolower($value);
if (in_array($lower, ['null', 'n/a', 'na', 'none', '-'], true)) {
return null;
}
return $value;
}
function channel_csv_boolish(?string $value, int $default = 1): int
{
$value = channel_csv_nullish($value);
if ($value === null) {
return $default;
}
$lower = strtolower($value);
if (in_array($lower, ['1', 'true', 'yes', 'y', 'active', 'enabled'], true)) {
return 1;
}
if (in_array($lower, ['0', 'false', 'no', 'n', 'inactive', 'disabled'], true)) {
return 0;
}
return is_numeric($value) ? ((int) $value > 0 ? 1 : 0) : $default;
}
function channel_csv_intish(?string $value, int $default = 0): int
{
$value = channel_csv_nullish($value);
if ($value === null) {
return $default;
}
return is_numeric($value) ? (int) $value : $default;
}
function channel_csv_date(?string $value): ?string
{
$value = channel_csv_nullish($value);
if ($value === null || $value === '0000-00-00') {
return null;
}
$formats = ['Y-m-d', 'd/m/Y', 'm/d/Y', 'd-m-Y', 'm-d-Y', 'd.m.Y', 'Y/m/d'];
foreach ($formats as $format) {
$date = DateTimeImmutable::createFromFormat($format, $value);
if ($date instanceof DateTimeImmutable) {
return $date->format('Y-m-d');
}
}
$timestamp = strtotime($value);
if ($timestamp === false) {
return null;
}
return gmdate('Y-m-d', $timestamp);
}
function channel_csv_datetime(?string $value): ?string
{
$value = channel_csv_nullish($value);
if ($value === null || $value === '0000-00-00 00:00:00') {
return null;
}
$formats = ['Y-m-d H:i:s', 'Y-m-d\TH:i:s', 'd/m/Y H:i:s', 'm/d/Y H:i:s', 'd-m-Y H:i:s', 'Y-m-d'];
foreach ($formats as $format) {
$date = DateTimeImmutable::createFromFormat($format, $value);
if ($date instanceof DateTimeImmutable) {
return $date->format('Y-m-d H:i:s');
}
}
$timestamp = strtotime($value);
if ($timestamp === false) {
return null;
}
return gmdate('Y-m-d H:i:s', $timestamp);
}
function channel_csv_type(?string $value): ?string
{
$value = channel_csv_nullish($value);
if ($value === null) {
return null;
}
$lower = strtolower($value);
if (str_contains($lower, 'free') || str_contains($lower, 'fta') || str_contains($lower, 'open')) {
return 'free';
}
if (str_contains($lower, 'pay')) {
return 'payed';
}
return $value;
}
function channel_import_workspace_csv(PDO $pdo, string $path): int
{
if (!is_file($path) || !is_readable($path)) {
return 0;
}
$csv = new SplFileObject($path, 'r');
$csv->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
$headers = null;
$headerMap = [];
$imported = 0;
foreach ($csv as $row) {
if (!is_array($row)) {
continue;
}
$hasContent = false;
foreach ($row as $cell) {
if (trim((string) $cell) !== '') {
$hasContent = true;
break;
}
}
if (!$hasContent) {
continue;
}
if ($headers === null) {
$headers = array_map(static fn($value): string => (string) $value, $row);
$headerMap = channel_csv_header_map($headers);
if (!isset($headerMap['upper_ch_ref'])) {
throw new RuntimeException('CSV import needs a channel name column (for example: upper_ch_ref or channel_name).');
}
continue;
}
$record = array_fill_keys(CHANNEL_RECORD_COLUMNS, null);
$record['active'] = 1;
$record['idtype'] = 1;
$record['counting'] = 0;
$record['manually_edited'] = 0;
foreach ($headerMap as $column => $index) {
$raw = isset($row[$index]) ? (string) $row[$index] : null;
switch ($column) {
case 'active':
$record[$column] = channel_csv_boolish($raw, 1);
break;
case 'idtype':
$record[$column] = channel_csv_intish($raw, 1);
break;
case 'counting':
$record[$column] = channel_csv_intish($raw, 0);
break;
case 'manually_edited':
$record[$column] = channel_csv_boolish($raw, 0);
break;
case 'sat_in':
case 'sat_out':
case 'sat_update':
case 'manually_edited_at':
case 'date_in':
case 'date_out':
$record[$column] = channel_csv_date($raw);
break;
case 'lastview':
$record[$column] = channel_csv_datetime($raw);
break;
case 'type':
$record[$column] = channel_csv_type($raw);
break;
default:
$record[$column] = channel_csv_nullish($raw);
break;
}
}
$record['upper_ch_ref'] = trim((string) ($record['upper_ch_ref'] ?? ''));
if ($record['upper_ch_ref'] === '') {
continue;
}
if ($record['country_iso'] !== null) {
$record['country_iso'] = strtoupper(substr((string) $record['country_iso'], 0, 3));
}
if ($record['date_in'] === null) {
$record['date_in'] = $record['sat_in'] ?? gmdate('Y-m-d');
}
channel_insert($record, $pdo);
$imported++;
}
return $imported;
}
function channel_app_bootstrap(): void
{
static $booted = false;
if ($booted) {
return;
}
$pdo = db();
$pdo->exec(
"CREATE TABLE IF NOT EXISTS channels (
id INT AUTO_INCREMENT PRIMARY KEY,
upper_ch_ref VARCHAR(255) NOT NULL,
country_iso VARCHAR(10) DEFAULT NULL,
country_client VARCHAR(100) DEFAULT NULL,
sat_ref VARCHAR(100) DEFAULT NULL,
sat_tpinfo VARCHAR(100) DEFAULT NULL,
genre VARCHAR(100) DEFAULT NULL,
type VARCHAR(20) DEFAULT NULL,
resolution VARCHAR(50) DEFAULT NULL,
langue VARCHAR(50) DEFAULT NULL,
region VARCHAR(100) DEFAULT NULL,
groupe VARCHAR(100) DEFAULT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
idtype TINYINT(1) NOT NULL DEFAULT 1,
six_id VARCHAR(50) DEFAULT NULL,
sid VARCHAR(50) DEFAULT NULL,
onid VARCHAR(50) DEFAULT NULL,
tid VARCHAR(50) DEFAULT NULL,
sat_in DATE DEFAULT NULL,
sat_out DATE DEFAULT NULL,
sat_update DATE DEFAULT NULL,
sat_ch_client_upper VARCHAR(255) DEFAULT NULL,
sat_client VARCHAR(255) DEFAULT NULL,
counting INT NOT NULL DEFAULT 0,
lastview DATETIME DEFAULT NULL,
manually_edited TINYINT(1) NOT NULL DEFAULT 0,
manually_edited_at DATE DEFAULT NULL,
date_in DATE DEFAULT NULL,
date_out DATE DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_active (active),
INDEX idx_sat_ref (sat_ref),
INDEX idx_country_iso (country_iso),
INDEX idx_genre (genre),
INDEX idx_idtype (idtype),
INDEX idx_manually_edited (manually_edited)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(100) NOT NULL,
channel_id INT NOT NULL,
field_name VARCHAR(100) NOT NULL,
old_value TEXT,
new_value TEXT,
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_channel_id (channel_id),
INDEX idx_user_id (user_id),
INDEX idx_field_name (field_name),
INDEX idx_changed_at (changed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$count = (int) $pdo->query("SELECT COUNT(*) FROM channels")->fetchColumn();
if ($count === 0) {
$csvSource = channel_detect_workspace_csv();
$imported = 0;
if ($csvSource !== null) {
try {
$imported = channel_import_workspace_csv($pdo, $csvSource['path']);
} catch (Throwable $e) {
error_log('CSV import failed for ' . ($csvSource['basename'] ?? 'workspace csv') . ': ' . $e->getMessage());
$imported = 0;
}
}
if ($imported === 0) {
$rows = [
['DISCOVERY SPORT', 'FR', 'France', 'EUTELSAT-5W', '11554/V/29950', 'Sport', 'free', 'HD', 'FR', 'West Europe', 'Warner Sports', 1, 1, '101001', '1201', '1', '301', '2024-01-04', null, '2026-03-15', 'DISCOVERY SPORT FR', 'Canal France', 1842, '2026-04-04 14:20:00', 0, null, '2024-01-04', null],
['EURO NEWS WORLD', 'GB', 'United Kingdom', 'ASTRA-28E', '11778/H/27500', 'News', 'free', 'HD', 'EN', 'UK & Ireland', 'Global News', 1, 1, '101002', '1202', '1', '302', '2024-02-12', null, '2026-03-20', 'EURONEWS UK', 'Sky UK', 2215, '2026-04-04 13:08:00', 1, '2026-04-03', '2024-02-12', null],
['CINEMA MAX', 'DE', 'Germany', 'HOTBIRD-13E', '11034/H/29900', 'Movie', 'payed', 'UHD', 'DE', 'DACH', 'Max Studios', 1, 1, '101003', '1203', '1', '303', '2023-11-01', null, '2026-03-29', 'CINEMA MAX DE', 'HD+ Germany', 1560, '2026-04-04 12:11:00', 0, null, '2023-11-01', null],
['KIDS ONE', 'IT', 'Italy', 'HOTBIRD-13E', '12149/V/27500', 'Kids', 'free', 'HD', 'IT', 'Southern Europe', 'Junior Media', 1, 1, '101004', '1204', '1', '304', '2024-04-18', null, '2026-03-30', 'KIDS ONE IT', 'Sky Italia', 980, '2026-04-03 11:42:00', 0, null, '2024-04-18', null],
['MUSIC HITS', 'ES', 'Spain', 'ASTRA-19E', '10847/V/22000', 'Music', 'free', 'SD', 'ES', 'Iberia', 'Nova Music', 1, 1, '101005', '1205', '1', '305', '2023-08-10', null, '2026-03-14', 'MUSIC HITS ES', 'Movistar+', 3104, '2026-04-04 08:15:00', 1, '2026-04-01', '2023-08-10', null],
['WORLD DOCS', 'US', 'United States', 'INTELSAT-34', '11720/H/45000', 'Documentary', 'payed', 'HD', 'EN', 'Americas', 'Discovery Group', 1, 1, '101006', '1206', '2', '306', '2022-06-20', null, '2026-03-25', 'WORLD DOCS US', 'Dish Americas', 884, '2026-04-04 16:20:00', 0, null, '2022-06-20', null],
['NEWS 24 AFRICA', 'ZA', 'South Africa', 'EUTELSAT-36E', '12360/R/27500', 'News', 'free', 'HD', 'EN', 'Africa', 'Africa Media', 1, 1, '101007', '1207', '7', '307', '2024-01-16', null, '2026-03-18', 'NEWS 24 AFRICA', 'MultiChoice', 1733, '2026-04-04 15:00:00', 0, null, '2024-01-16', null],
['SPORT PLUS 4K', 'PL', 'Poland', 'HOTBIRD-13E', '10758/V/30000', 'Sport', 'payed', 'UHD', 'PL', 'Central Europe', 'Polsat Sport', 1, 1, '101008', '1208', '3', '308', '2024-03-05', null, '2026-03-27', 'SPORT PLUS 4K', 'Cyfrowy Polsat', 1270, '2026-04-04 12:55:00', 1, '2026-04-02', '2024-03-05', null],
['ARTE CULTURE', 'FR', 'France', 'ASTRA-19E', '11376/V/22000', 'Culture', 'free', 'HD', 'FR', 'West Europe', 'Arte Group', 1, 1, '101009', '1209', '1', '309', '2023-02-14', null, '2026-03-21', 'ARTE CULTURE FR', 'Canal France', 2011, '2026-04-04 10:30:00', 0, null, '2023-02-14', null],
['MOVIES ACTION', 'TR', 'Turkey', 'TURKSAT-42E', '11844/V/2222', 'Movie', 'payed', 'HD', 'TR', 'Middle East', 'Action Studios', 0, 1, '101010', '1210', '4', '310', '2024-05-12', '2026-03-01', '2026-03-01', 'MOVIES ACTION TR', 'D-Smart', 542, '2026-03-01 09:00:00', 1, '2026-03-01', '2024-05-12', null],
['UNKNOWN HIT 1198', null, 'Unmatched', 'EUTELSAT-7B', '11200/H/30000', null, null, null, null, null, null, 1, 2, '101011', '1211', '9', '311', '2026-03-22', null, '2026-03-22', 'UNMATCHED FEED', 'Pushimatic', 14, '2026-04-04 05:55:00', 0, null, '2026-03-22', null],
['LATAM SPORT EXTRA', 'BR', 'Brazil', 'INTELSAT-34', '12050/V/45000', 'Sport', 'payed', 'HD', 'PT', 'LATAM', 'Globo Sports', 1, 1, '101012', '1212', '6', '312', '2024-07-01', null, '2026-03-31', 'LATAM SPORT EXTRA', 'Claro TV', 698, '2026-04-04 18:05:00', 0, null, '2024-07-01', null],
['ASIA BUSINESS TV', 'SG', 'Singapore', 'ASIASAT-7', '12210/H/43200', 'Business', 'free', 'HD', 'EN', 'Asia Pacific', 'Asia Finance', 1, 1, '101013', '1213', '5', '313', '2023-09-09', null, '2026-03-26', 'ASIA BUSINESS TV', 'StarHub', 431, '2026-04-02 06:12:00', 0, null, '2023-09-09', null],
['FAMILY LIFE', 'US', 'United States', 'EUTELSAT-5W', '12648/H/29500', 'Lifestyle', 'free', 'HD', 'EN', 'North America', 'Family Media', 1, 1, '101014', '1214', '2', '314', '2024-01-28', null, '2026-03-17', 'FAMILY LIFE US', 'Dish Americas', 799, '2026-04-03 19:44:00', 1, '2026-03-28', '2024-01-28', null],
['SCIENCE HUB', 'CA', 'Canada', 'INTELSAT-34', '12190/V/45000', 'Education', 'payed', 'HD', 'EN', 'North America', 'Knowledge Networks', 1, 1, '101015', '1215', '2', '315', '2024-06-11', null, '2026-03-19', 'SCIENCE HUB CA', 'Bell TV', 611, '2026-04-04 07:12:00', 0, null, '2024-06-11', null],
['RETRO CINEMA', 'MX', 'Mexico', 'SATMEX-8', '11180/H/30000', 'Movie', 'free', 'SD', 'ES', 'LATAM', 'Retro Media', 1, 1, '101016', '1216', '8', '316', '2022-10-03', null, '2026-03-11', 'RETRO CINEMA MX', 'Sky Mexico', 1221, '2026-04-04 09:40:00', 0, null, '2022-10-03', null],
];
$stmt = $pdo->prepare(
"INSERT INTO channels (
upper_ch_ref, country_iso, country_client, sat_ref, sat_tpinfo, genre, type, resolution, langue,
region, groupe, active, idtype, six_id, sid, onid, tid, sat_in, sat_out, sat_update,
sat_ch_client_upper, sat_client, counting, lastview, manually_edited, manually_edited_at, date_in, date_out
) VALUES (
:upper_ch_ref, :country_iso, :country_client, :sat_ref, :sat_tpinfo, :genre, :type, :resolution, :langue,
:region, :groupe, :active, :idtype, :six_id, :sid, :onid, :tid, :sat_in, :sat_out, :sat_update,
:sat_ch_client_upper, :sat_client, :counting, :lastview, :manually_edited, :manually_edited_at, :date_in, :date_out
)"
);
foreach ($rows as $row) {
$stmt->execute([
':upper_ch_ref' => $row[0],
':country_iso' => $row[1],
':country_client' => $row[2],
':sat_ref' => $row[3],
':sat_tpinfo' => $row[4],
':genre' => $row[5],
':type' => $row[6],
':resolution' => $row[7],
':langue' => $row[8],
':region' => $row[9],
':groupe' => $row[10],
':active' => $row[11],
':idtype' => $row[12],
':six_id' => $row[13],
':sid' => $row[14],
':onid' => $row[15],
':tid' => $row[16],
':sat_in' => $row[17],
':sat_out' => $row[18],
':sat_update' => $row[19],
':sat_ch_client_upper' => $row[20],
':sat_client' => $row[21],
':counting' => $row[22],
':lastview' => $row[23],
':manually_edited' => $row[24],
':manually_edited_at' => $row[25],
':date_in' => $row[26],
':date_out' => $row[27],
]);
}
$auditStmt = $pdo->prepare(
"INSERT INTO audit_log (user_id, channel_id, field_name, old_value, new_value, changed_at)
VALUES (:user_id, :channel_id, :field_name, :old_value, :new_value, :changed_at)"
);
$auditStmt->execute([
':user_id' => 'system.seed',
':channel_id' => 2,
':field_name' => 'genre',
':old_value' => 'International',
':new_value' => 'News',
':changed_at' => '2026-04-03 09:14:00',
]);
$auditStmt->execute([
':user_id' => 'ops.demo',
':channel_id' => 8,
':field_name' => 'resolution',
':old_value' => 'HD',
':new_value' => 'UHD',
':changed_at' => '2026-04-02 17:20:00',
]);
$auditStmt->execute([
':user_id' => 'ops.demo',
':channel_id' => 14,
':field_name' => 'active',
':old_value' => '0',
':new_value' => '1',
':changed_at' => '2026-03-28 12:03:00',
]);
}
}
$booted = true;
}
function channel_current_user_id(): string
{
return $_SERVER['REMOTE_USER'] ?? $_SERVER['PHP_AUTH_USER'] ?? 'ops.demo';
}
function channel_allowed_sort_columns(): array
{
return [
'upper_ch_ref', 'country_iso', 'country_client', 'sat_ref', 'sat_tpinfo', 'genre', 'type', 'resolution', 'langue',
'region', 'groupe', 'active', 'idtype', 'six_id', 'sid', 'onid', 'tid', 'sat_in', 'sat_out', 'sat_update',
'sat_ch_client_upper', 'sat_client', 'counting', 'lastview', 'manually_edited_at'
];
}
function channel_parse_csv_param(string $key): array
{
if (!isset($_GET[$key])) {
return [];
}
$value = $_GET[$key];
if (is_array($value)) {
$items = $value;
} else {
$items = explode(',', (string) $value);
}
$clean = [];
foreach ($items as $item) {
$item = trim((string) $item);
if ($item !== '') {
$clean[] = $item;
}
}
return array_values(array_unique($clean));
}
function channel_parse_advanced_filters(mixed $rawFilters): array
{
if (is_string($rawFilters)) {
$decoded = json_decode($rawFilters, true);
$rawFilters = is_array($decoded) ? $decoded : [];
}
if (!is_array($rawFilters)) {
return [];
}
$allowedFields = CHANNEL_ADVANCED_FILTER_FIELDS;
$allowedOperators = ['contains', 'equals', 'not_contains', 'not_equals', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty'];
$normalized = [];
foreach ($rawFilters as $rule) {
if (!is_array($rule)) {
continue;
}
$field = trim((string) ($rule['field'] ?? ''));
$operator = trim((string) ($rule['operator'] ?? 'contains'));
$value = trim((string) ($rule['value'] ?? ''));
if (!in_array($field, $allowedFields, true) || !in_array($operator, $allowedOperators, true)) {
continue;
}
if (!in_array($operator, ['is_empty', 'is_not_empty'], true) && $value === '') {
continue;
}
$normalized[] = [
'field' => $field,
'operator' => $operator,
'value' => $value,
];
if (count($normalized) >= 10) {
break;
}
}
return $normalized;
}
function channel_apply_advanced_filters(array $rules, array &$where, array &$params): bool
{
$applied = false;
foreach ($rules as $index => $rule) {
$field = $rule['field'];
$operator = $rule['operator'];
$value = $rule['value'] ?? '';
$placeholder = ':adv_' . $index;
switch ($operator) {
case 'contains':
$where[] = "{$field} LIKE {$placeholder}";
$params[$placeholder] = '%' . $value . '%';
$applied = true;
break;
case 'equals':
$where[] = "{$field} = {$placeholder}";
$params[$placeholder] = $value;
$applied = true;
break;
case 'not_contains':
$where[] = "({$field} IS NULL OR {$field} NOT LIKE {$placeholder})";
$params[$placeholder] = '%' . $value . '%';
$applied = true;
break;
case 'not_equals':
$where[] = "({$field} IS NULL OR {$field} <> {$placeholder})";
$params[$placeholder] = $value;
$applied = true;
break;
case 'starts_with':
$where[] = "{$field} LIKE {$placeholder}";
$params[$placeholder] = $value . '%';
$applied = true;
break;
case 'ends_with':
$where[] = "{$field} LIKE {$placeholder}";
$params[$placeholder] = '%' . $value;
$applied = true;
break;
case 'is_empty':
$where[] = "({$field} IS NULL OR {$field} = '')";
$applied = true;
break;
case 'is_not_empty':
$where[] = "({$field} IS NOT NULL AND {$field} <> '')";
$applied = true;
break;
}
}
return $applied;
}
function channel_distinct_options(array $fields): array
{
channel_app_bootstrap();
$pdo = db();
$allowed = array_values(array_intersect($fields, CHANNEL_FILTER_OPTION_FIELDS));
$out = [];
foreach ($allowed as $field) {
$stmt = $pdo->query("SELECT DISTINCT {$field} AS value FROM channels WHERE date_out IS NULL AND {$field} IS NOT NULL AND {$field} <> '' ORDER BY {$field} ASC");
$out[$field] = array_values(array_map(static fn(array $row): string => (string) $row['value'], $stmt->fetchAll()));
}
return $out;
}
function channel_last_changed_field_map(array $channelIds): array
{
$channelIds = array_values(array_unique(array_map('intval', array_filter($channelIds, static fn($id): bool => (int) $id > 0))));
if (!$channelIds) {
return [];
}
$pdo = db();
$placeholders = implode(',', array_fill(0, count($channelIds), '?'));
$sql = "SELECT a.channel_id, a.field_name
FROM audit_log a
INNER JOIN (
SELECT channel_id, MAX(id) AS max_id
FROM audit_log
WHERE field_name <> 'versioned_from' AND channel_id IN ({$placeholders})
GROUP BY channel_id
) latest ON latest.max_id = a.id";
$stmt = $pdo->prepare($sql);
foreach ($channelIds as $index => $channelId) {
$stmt->bindValue($index + 1, $channelId, PDO::PARAM_INT);
}
$stmt->execute();
$map = [];
foreach ($stmt->fetchAll() as $row) {
$map[(int) $row['channel_id']] = (string) $row['field_name'];
}
return $map;
}
function channel_list(array $query): array
{
channel_app_bootstrap();
$pdo = db();
$limit = isset($query['limit']) ? max(1, min(100, (int) $query['limit'])) : 50;
$page = isset($query['page']) ? max(1, (int) $query['page']) : 1;
$offset = ($page - 1) * $limit;
$sortable = channel_allowed_sort_columns();
$sort = in_array(($query['sort'] ?? ''), $sortable, true) ? (string) $query['sort'] : 'upper_ch_ref';
$direction = strtoupper((string) ($query['direction'] ?? 'ASC')) === 'DESC' ? 'DESC' : 'ASC';
$filterMap = [
'sat_ref' => 'sat_ref',
'country_iso' => 'country_iso',
'country_client' => 'country_client',
'genre' => 'genre',
'type' => 'type',
'resolution' => 'resolution',
'langue' => 'langue',
'region' => 'region',
'groupe' => 'groupe',
'active' => 'active',
'idtype' => 'idtype',
'manually_edited' => 'manually_edited',
'sat_tpinfo' => 'sat_tpinfo',
'sat_ch_client_upper' => 'sat_ch_client_upper',
'sat_client' => 'sat_client',
'counting' => 'counting',
'lastview' => 'lastview',
'sat_in' => 'sat_in',
'sat_out' => 'sat_out',
'sat_update' => 'sat_update',
'manually_edited_at' => 'manually_edited_at',
];
$where = [];
$params = [];
$hasExplicitFilter = false;
if (!empty($query['search'])) {
$hasExplicitFilter = true;
$where[] = "(
upper_ch_ref LIKE :search OR country_iso LIKE :search OR country_client LIKE :search OR sat_ref LIKE :search OR
sat_tpinfo LIKE :search OR genre LIKE :search OR type LIKE :search OR resolution LIKE :search OR langue LIKE :search OR
region LIKE :search OR groupe LIKE :search OR six_id LIKE :search OR sid LIKE :search OR onid LIKE :search OR tid LIKE :search OR
sat_client LIKE :search OR sat_ch_client_upper LIKE :search OR counting LIKE :search OR lastview LIKE :search OR
sat_in LIKE :search OR sat_out LIKE :search OR sat_update LIKE :search OR manually_edited_at LIKE :search
)";
$params[':search'] = '%' . $query['search'] . '%';
}
$advancedFilters = channel_parse_advanced_filters($query['advanced_filters'] ?? []);
if ($advancedFilters) {
$hasExplicitFilter = channel_apply_advanced_filters($advancedFilters, $where, $params) || $hasExplicitFilter;
}
foreach ($filterMap as $param => $column) {
$values = [];
if (isset($query[$param])) {
if (is_array($query[$param])) {
$values = $query[$param];
} else {
$values = explode(',', (string) $query[$param]);
}
}
$values = array_values(array_filter(array_map(static fn($v) => trim((string) $v), $values), static fn($v) => $v !== ''));
if ($values) {
$hasExplicitFilter = true;
$placeholders = [];
foreach ($values as $index => $value) {
$name = ':' . $param . '_' . $index;
$placeholders[] = $name;
$params[$name] = $value;
}
$where[] = sprintf('%s IN (%s)', $column, implode(',', $placeholders));
}
}
foreach (['six_id', 'sid', 'onid', 'tid'] as $exactField) {
if (!empty($query[$exactField])) {
$hasExplicitFilter = true;
$where[] = "{$exactField} = :{$exactField}";
$params[':' . $exactField] = (string) $query[$exactField];
}
}
$where[] = 'date_out IS NULL';
if (!$hasExplicitFilter) {
$where[] = 'active = 1';
$where[] = 'sat_out IS NULL';
}
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM channels {$whereSql}");
foreach ($params as $key => $value) {
$countStmt->bindValue($key, $value);
}
$countStmt->execute();
$total = (int) $countStmt->fetchColumn();
$sql = sprintf(
'SELECT %s FROM channels %s ORDER BY %s %s LIMIT :limit OFFSET :offset',
implode(', ', CHANNEL_LIST_COLUMNS),
$whereSql,
$sort,
$direction
);
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
$lastChangedFields = channel_last_changed_field_map(array_map(static fn(array $row): int => (int) $row['id'], $rows));
foreach ($rows as &$row) {
$row['last_changed_field'] = $lastChangedFields[(int) ($row['id'] ?? 0)] ?? null;
}
unset($row);
return [
'data' => $rows,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'pages' => (int) max(1, ceil($total / $limit)),
],
'meta' => [
'sort' => $sort,
'direction' => $direction,
'filters_applied' => $hasExplicitFilter,
],
'options' => channel_distinct_options(CHANNEL_FILTER_OPTION_FIELDS),
];
}
function channel_get(int $id): ?array
{
channel_app_bootstrap();
$stmt = db()->prepare('SELECT * FROM channels WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch();
return $row ?: null;
}
function channel_insert(array $record, ?PDO $pdo = null): int
{
$pdo = $pdo ?: db();
$columns = CHANNEL_RECORD_COLUMNS;
$placeholders = array_map(static fn(string $column): string => ':' . $column, $columns);
$sql = sprintf(
'INSERT INTO channels (%s) VALUES (%s)',
implode(', ', $columns),
implode(', ', $placeholders)
);
$stmt = $pdo->prepare($sql);
foreach ($columns as $column) {
$stmt->bindValue(':' . $column, $record[$column] ?? null);
}
$stmt->execute();
return (int) $pdo->lastInsertId();
}
function channel_close_and_version(int $id, array $changes, string $userId): array
{
$pdo = db();
$channel = channel_get($id);
if (!$channel) {
throw new InvalidArgumentException('Channel not found.');
}
if ($channel['date_out'] !== null) {
throw new InvalidArgumentException('Only the current channel version can be edited.');
}
$versionDate = gmdate('Y-m-d');
$nextRecord = [];
foreach (CHANNEL_RECORD_COLUMNS as $column) {
$nextRecord[$column] = $channel[$column] ?? null;
}
foreach ($changes as $field => $change) {
$nextRecord[$field] = $change['new'];
}
$nextRecord['manually_edited'] = 1;
$nextRecord['manually_edited_at'] = $versionDate;
$nextRecord['date_in'] = $versionDate;
$nextRecord['date_out'] = null;
$pdo->beginTransaction();
try {
$closeStmt = $pdo->prepare('UPDATE channels SET date_out = :date_out WHERE id = :id AND date_out IS NULL');
$closeStmt->bindValue(':date_out', $versionDate);
$closeStmt->bindValue(':id', $id, PDO::PARAM_INT);
$closeStmt->execute();
if ($closeStmt->rowCount() !== 1) {
throw new RuntimeException('Unable to close the previous channel version.');
}
$newId = channel_insert($nextRecord, $pdo);
$auditStmt = $pdo->prepare(
'INSERT INTO audit_log (user_id, channel_id, field_name, old_value, new_value) VALUES (:user_id, :channel_id, :field_name, :old_value, :new_value)'
);
foreach ($changes as $field => $change) {
$auditStmt->execute([
':user_id' => $userId,
':channel_id' => $newId,
':field_name' => $field,
':old_value' => $change['old'] === null ? null : (string) $change['old'],
':new_value' => $change['new'] === null ? null : (string) $change['new'],
]);
}
$auditStmt->execute([
':user_id' => $userId,
':channel_id' => $newId,
':field_name' => 'versioned_from',
':old_value' => (string) $id,
':new_value' => (string) $newId,
]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
return [
'previous_channel_id' => $id,
'channel' => channel_get($newId),
'changed_fields' => array_keys($changes),
];
}
function channel_validate_patch_fields(array $fields): array
{
if (!$fields) {
throw new InvalidArgumentException('No editable fields were provided.');
}
$clean = [];
$editable = CHANNEL_EDITABLE_FIELDS;
$existingSatRefs = channel_distinct_options(['sat_ref'])['sat_ref'];
foreach ($fields as $field => $value) {
if (!in_array($field, $editable, true)) {
throw new RuntimeException('FORBIDDEN_FIELD:' . $field);
}
if (in_array($field, ['sat_in', 'sat_out', 'sat_update'], true)) {
if ($value === '' || $value === null) {
$clean[$field] = null;
continue;
}
$date = date_create((string) $value);
if (!$date) {
throw new InvalidArgumentException("Invalid date for {$field}.");
}
$clean[$field] = $date->format('Y-m-d');
continue;
}
if ($field === 'active') {
if (!in_array((string) $value, ['0', '1'], true)) {
throw new InvalidArgumentException('Active must be 0 or 1.');
}
$clean[$field] = (int) $value;
continue;
}
if ($field === 'type') {
if (!in_array((string) $value, ['free', 'payed'], true)) {
throw new InvalidArgumentException('Type must be free or payed.');
}
$clean[$field] = (string) $value;
continue;
}
if ($field === 'country_iso' && $value !== null && $value !== '') {
$value = strtoupper(substr((string) $value, 0, 3));
}
if ($field === 'sat_ref' && $value !== null && $value !== '' && !in_array((string) $value, $existingSatRefs, true)) {
throw new InvalidArgumentException('sat_ref must match an existing satellite reference.');
}
$clean[$field] = $value === '' ? null : trim((string) $value);
}
return $clean;
}
function channel_patch(int $id, array $fields, string $userId): array
{
channel_app_bootstrap();
$channel = channel_get($id);
if (!$channel) {
throw new InvalidArgumentException('Channel not found.');
}
$clean = channel_validate_patch_fields($fields);
$changes = [];
foreach ($clean as $field => $newValue) {
$oldValue = $channel[$field];
if ((string) ($oldValue ?? '') !== (string) ($newValue ?? '')) {
$changes[$field] = ['old' => $oldValue, 'new' => $newValue];
}
}
if (!$changes) {
return ['previous_channel_id' => $id, 'channel' => $channel, 'changed_fields' => []];
}
return channel_close_and_version($id, $changes, $userId);
}
function channel_bulk_patch(array $ids, array $fields, string $userId): array
{
$ids = array_values(array_unique(array_map('intval', $ids)));
$ids = array_values(array_filter($ids, static fn(int $id): bool => $id > 0));
if (!$ids) {
throw new InvalidArgumentException('No valid ids were provided for bulk update.');
}
$cleanFields = channel_validate_patch_fields($fields);
$updated = [];
foreach ($ids as $id) {
$result = channel_patch($id, $cleanFields, $userId);
if (!empty($result['changed_fields'])) {
$updated[] = [
'previous_id' => $id,
'new_id' => (int) ($result['channel']['id'] ?? 0),
'changed_fields' => $result['changed_fields'],
];
}
}
return [
'updated_count' => count($updated),
'items' => $updated,
];
}
function channel_build_latest_transition(array $versions): array
{
if (!$versions) {
return [
'current_id' => null,
'previous_id' => null,
'changed_fields' => [],
'changes' => [],
'last_changed_field' => null,
'last_change' => null,
];
}
$currentIndex = null;
foreach ($versions as $index => $version) {
if (!empty($version['is_current'])) {
$currentIndex = $index;
break;
}
}
if ($currentIndex === null) {
$currentIndex = count($versions) - 1;
}
$currentVersion = $versions[$currentIndex] ?? null;
$previousVersion = $currentIndex > 0 ? ($versions[$currentIndex - 1] ?? null) : null;
if (!$currentVersion || !$previousVersion) {
return [
'current_id' => $currentVersion['id'] ?? null,
'previous_id' => $previousVersion['id'] ?? null,
'changed_fields' => [],
'changes' => [],
'last_changed_field' => null,
'last_change' => null,
];
}
$changes = [];
foreach (CHANNEL_EDITABLE_FIELDS as $field) {
$oldValue = $previousVersion[$field] ?? null;
$newValue = $currentVersion[$field] ?? null;
if ((string) ($oldValue ?? '') === (string) ($newValue ?? '')) {
continue;
}
$changes[] = [
'field_name' => $field,
'old_value' => $oldValue,
'new_value' => $newValue,
];
}
$lastChange = null;
$currentChanges = $currentVersion['changes'] ?? [];
if ($currentChanges) {
$lastAuditChange = $currentChanges[count($currentChanges) - 1];
$lastField = (string) ($lastAuditChange['field_name'] ?? '');
foreach ($changes as $change) {
if (($change['field_name'] ?? '') === $lastField) {
$lastChange = $change;
break;
}
}
}
if ($lastChange === null && $changes) {
$lastChange = $changes[count($changes) - 1];
}
return [
'current_id' => $currentVersion['id'] ?? null,
'previous_id' => $previousVersion['id'] ?? null,
'changed_fields' => array_values(array_map(static fn(array $change): string => $change['field_name'], $changes)),
'changes' => $changes,
'last_changed_field' => $lastChange['field_name'] ?? null,
'last_change' => $lastChange,
];
}
function channel_history(int $id): array
{
channel_app_bootstrap();
$pdo = db();
$selected = channel_get($id);
if (!$selected) {
throw new InvalidArgumentException('Channel not found.');
}
$backwardIds = [];
$cursor = $id;
while (true) {
$stmt = $pdo->prepare("SELECT old_value FROM audit_log WHERE channel_id = :channel_id AND field_name = 'versioned_from' ORDER BY id DESC LIMIT 1");
$stmt->bindValue(':channel_id', $cursor, PDO::PARAM_INT);
$stmt->execute();
$previousId = (int) $stmt->fetchColumn();
if ($previousId <= 0 || in_array($previousId, $backwardIds, true)) {
break;
}
$backwardIds[] = $previousId;
$cursor = $previousId;
}
$forwardIds = [];
$cursor = $id;
while (true) {
$stmt = $pdo->prepare("SELECT new_value FROM audit_log WHERE field_name = 'versioned_from' AND old_value = :old_value ORDER BY id ASC LIMIT 1");
$stmt->bindValue(':old_value', (string) $cursor);
$stmt->execute();
$nextId = (int) $stmt->fetchColumn();
if ($nextId <= 0 || in_array($nextId, $forwardIds, true)) {
break;
}
$forwardIds[] = $nextId;
$cursor = $nextId;
}
$orderedIds = array_values(array_unique(array_merge(array_reverse($backwardIds), [$id], $forwardIds)));
$placeholders = implode(', ', array_fill(0, count($orderedIds), '?'));
$rowStmt = $pdo->prepare("SELECT * FROM channels WHERE id IN ({$placeholders})");
foreach ($orderedIds as $index => $versionId) {
$rowStmt->bindValue($index + 1, $versionId, PDO::PARAM_INT);
}
$rowStmt->execute();
$rows = $rowStmt->fetchAll();
$rowsById = [];
foreach ($rows as $row) {
$rowsById[(int) $row['id']] = $row;
}
$auditStmt = $pdo->prepare("SELECT channel_id, user_id, field_name, old_value, new_value, changed_at FROM audit_log WHERE channel_id IN ({$placeholders}) ORDER BY changed_at ASC, id ASC");
foreach ($orderedIds as $index => $versionId) {
$auditStmt->bindValue($index + 1, $versionId, PDO::PARAM_INT);
}
$auditStmt->execute();
$changesByChannel = [];
$previousMap = [];
foreach ($auditStmt->fetchAll() as $entry) {
$channelId = (int) $entry['channel_id'];
if (($entry['field_name'] ?? '') === 'versioned_from') {
$previousMap[$channelId] = (int) ($entry['old_value'] ?? 0);
continue;
}
$changesByChannel[$channelId][] = [
'field_name' => (string) $entry['field_name'],
'old_value' => $entry['old_value'],
'new_value' => $entry['new_value'],
'changed_at' => $entry['changed_at'],
'user_id' => $entry['user_id'],
];
}
$versions = [];
foreach ($orderedIds as $position => $versionId) {
if (!isset($rowsById[$versionId])) {
continue;
}
$row = $rowsById[$versionId];
$changes = $changesByChannel[$versionId] ?? [];
$versions[] = [
'id' => (int) $row['id'],
'version_number' => $position + 1,
'is_current' => $row['date_out'] === null,
'previous_id' => $previousMap[$versionId] ?? null,
'upper_ch_ref' => $row['upper_ch_ref'],
'country_iso' => $row['country_iso'],
'sat_ref' => $row['sat_ref'],
'genre' => $row['genre'],
'type' => $row['type'],
'groupe' => $row['groupe'],
'region' => $row['region'],
'langue' => $row['langue'],
'resolution' => $row['resolution'],
'active' => $row['active'],
'sat_in' => $row['sat_in'],
'sat_out' => $row['sat_out'],
'sat_update' => $row['sat_update'],
'manually_edited' => $row['manually_edited'],
'manually_edited_at' => $row['manually_edited_at'],
'date_in' => $row['date_in'],
'date_out' => $row['date_out'],
'changes' => $changes,
'changed_fields' => array_values(array_unique(array_map(static fn(array $change): string => $change['field_name'], $changes))),
];
}
return [
'selected_id' => $id,
'root_id' => $orderedIds[0] ?? $id,
'latest_id' => $orderedIds ? $orderedIds[count($orderedIds) - 1] : $id,
'total_versions' => count($versions),
'latest_transition' => channel_build_latest_transition($versions),
'versions' => $versions,
];
}
function channel_stats(): array
{
channel_app_bootstrap();
$pdo = db();
$overview = $pdo->query(
'SELECT COUNT(*) AS total_channels, SUM(active = 1 AND sat_out IS NULL) AS active_channels, SUM(manually_edited = 1) AS manually_edited_channels, COALESCE(SUM(counting), 0) AS total_hits FROM channels WHERE date_out IS NULL'
)->fetch();
$dimensions = ['sat_ref', 'country_iso', 'genre', 'type', 'resolution', 'region'];
$breakdowns = [];
foreach ($dimensions as $dimension) {
$stmt = $pdo->query("SELECT COALESCE({$dimension}, 'Unknown') AS label, COUNT(*) AS total FROM channels WHERE date_out IS NULL GROUP BY {$dimension} ORDER BY total DESC, label ASC LIMIT 8");
$breakdowns[$dimension] = $stmt->fetchAll();
}
return [
'overview' => $overview,
'breakdowns' => $breakdowns,
];
}
function audit_log_list(array $query): array
{
channel_app_bootstrap();
$pdo = db();
$page = isset($query['page']) ? max(1, (int) $query['page']) : 1;
$limit = isset($query['limit']) ? max(1, min(100, (int) $query['limit'])) : 20;
$offset = ($page - 1) * $limit;
$where = [];
$params = [];
if (!empty($query['user_id'])) {
$where[] = 'a.user_id = :user_id';
$params[':user_id'] = (string) $query['user_id'];
}
if (!empty($query['field_name'])) {
$where[] = 'a.field_name = :field_name';
$params[':field_name'] = (string) $query['field_name'];
}
if (!empty($query['date_from'])) {
$where[] = 'DATE(a.changed_at) >= :date_from';
$params[':date_from'] = (string) $query['date_from'];
}
if (!empty($query['date_to'])) {
$where[] = 'DATE(a.changed_at) <= :date_to';
$params[':date_to'] = (string) $query['date_to'];
}
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM audit_log a {$whereSql}");
foreach ($params as $key => $value) {
$countStmt->bindValue($key, $value);
}
$countStmt->execute();
$total = (int) $countStmt->fetchColumn();
$sql = "SELECT a.*, c.upper_ch_ref, c.sat_ref
FROM audit_log a
LEFT JOIN channels c ON c.id = a.channel_id
{$whereSql}
ORDER BY a.changed_at DESC, a.id DESC
LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return [
'data' => $stmt->fetchAll(),
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'pages' => (int) max(1, ceil($total / $limit)),
],
'filters' => [
'users' => channel_distinct_audit_values('user_id'),
'fields' => channel_distinct_audit_values('field_name'),
],
];
}
function channel_distinct_audit_values(string $field): array
{
$allowed = ['user_id', 'field_name'];
if (!in_array($field, $allowed, true)) {
return [];
}
$stmt = db()->query("SELECT DISTINCT {$field} AS value FROM audit_log ORDER BY {$field} ASC");
return array_values(array_map(static fn(array $row): string => (string) $row['value'], $stmt->fetchAll()));
}