commit 892f6e3196bc1da040fc5c986c210cb52e013d05 Author: Anton Afanasyeu Date: Tue Jun 23 12:29:32 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..a672ef3 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-ms-issues + +Microservice API. Remote: `git://f0xx.org/ac/ac-ms-issues` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..842fd83 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "androidcast/ms-issues", + "description": "Crash/report ingest and live cast APIs", + "type": "project", + "require": { + "php": ">=8.1", + "androidcast/platform-php": "dev-next", + "androidcast/platform-db": "dev-next" + }, + "repositories": [ + { + "type": "vcs", + "url": "git://f0xx.org/ac/ac-platform-php" + }, + { + "type": "vcs", + "url": "git://f0xx.org/ac/ac-platform-db" + } + ], + "autoload": { + "classmap": [ + "src/" + ] + } +} diff --git a/config/config.example.php b/config/config.example.php new file mode 100644 index 0000000..9a59c16 --- /dev/null +++ b/config/config.example.php @@ -0,0 +1,2 @@ + ['driver' => 'sqlite']]; diff --git a/public/api/diag.php b/public/api/diag.php new file mode 100644 index 0000000..ae36bdc --- /dev/null +++ b/public/api/diag.php @@ -0,0 +1,77 @@ + false, 'error' => 'diag disabled'], 403); +} + +$driver = Database::driver(); +$path = cfg('db.sqlite_path'); +$out = [ + 'ok' => true, + 'db_driver' => $driver, + 'php_user' => get_current_user(), + 'storage_writable' => is_writable(dirname(__DIR__) . '/../storage'), + 'tables' => [], + 'reports_columns' => [], + 'test_insert' => null, +]; + +if ($driver === 'sqlite') { + $out['sqlite_path'] = $path; + $out['sqlite_realpath'] = is_string($path) ? realpath($path) : null; + $out['sqlite_exists'] = is_string($path) && is_file($path); + $out['sqlite_writable'] = is_string($path) && is_writable($path); + $out['data_dir_writable'] = is_string($path) && is_writable(dirname($path)); +} else { + $m = cfg('db.mysql', []); + $socket = trim((string) ($m['socket'] ?? '')); + $out['mysql'] = [ + 'host' => $m['host'] ?? '127.0.0.1', + 'port' => (int) ($m['port'] ?? 3306), + 'socket' => $socket, + 'socket_exists' => $socket !== '' && file_exists($socket), + 'database' => $m['database'] ?? 'androidcast_crashes', + 'username' => $m['username'] ?? '', + ]; +} + +try { + $pdo = Database::pdo(); + $tables = Database::listTables($pdo); + $out['tables'] = $tables; + $out['tickets_table'] = Database::ticketsTableExists($pdo); + if (Database::isMysql()) { + try { + $out['mysql_database'] = $pdo->query('SELECT DATABASE()')->fetchColumn(); + } catch (Throwable $e) { + $out['mysql_database'] = $e->getMessage(); + } + } + if (!$out['tickets_table']) { + $out['tickets_migration'] = Database::ticketsMigrationHint(); + } + if (in_array('reports', $tables, true)) { + $cols = Database::columnNames($pdo, 'reports'); + $out['reports_columns'] = array_map( + static fn (string $name) => ['name' => $name], + $cols + ); + } + $pdo->beginTransaction(); + $stmt = $pdo->prepare( + 'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, + device_model, app_version, payload_json, tags_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $rid = 'diag-' . time(); + $stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}', '[]']); + $pdo->rollBack(); + $out['test_insert'] = 'ok (rolled back)'; +} catch (Throwable $e) { + $out['ok'] = false; + $out['test_insert'] = $e->getMessage(); +} + +json_out($out); diff --git a/public/api/live_cast.php b/public/api/live_cast.php new file mode 100644 index 0000000..fd6fe0c --- /dev/null +++ b/public/api/live_cast.php @@ -0,0 +1,114 @@ + false, 'error' => 'invalid_json'], 400); + } + $action = trim((string) ($body['action'] ?? '')); +} + +$public = ($method === 'GET' && $action === 'session') + || ($method === 'POST' && $action === 'join_event'); + +if ($method === 'POST' && in_array($action, ['create_intent', 'heartbeat'], true)) { + $platform = strtolower(trim((string) ($body['platform'] ?? ''))); + if ($action === 'create_intent' && str_starts_with($platform, 'education')) { + $public = true; + } + if ($action === 'heartbeat') { + $sid = trim((string) ($body['session_id'] ?? '')); + if ($sid !== '') { + $existing = LiveCastRepository::sessionById($sid); + $existingPlatform = strtolower((string) ($existing['platform'] ?? '')); + if ($existing !== null && str_starts_with($existingPlatform, 'education')) { + $public = true; + } + } + } +} + +if (!$public) { + Auth::check(); +} +$actor = Auth::user() ?? []; + +if ($method === 'GET') { + if ($action === 'active') { + $platform = trim((string) ($_GET['platform'] ?? '')); + $rows = LiveCastRepository::activeSessions($actor, $platform !== '' ? $platform : null); + json_out(['ok' => true, 'sessions' => $rows]); + } + if ($action === 'analytics') { + $days = (int) ($_GET['days'] ?? 14); + $payload = LiveCastRepository::analytics($days, $actor); + json_out(['ok' => true, 'data' => $payload]); + } + if ($action === 'list') { + $days = (int) ($_GET['days'] ?? 14); + $limit = (int) ($_GET['limit'] ?? 200); + $rows = LiveCastRepository::listSessions($days, $actor, $limit); + json_out(['ok' => true, 'sessions' => $rows]); + } + if ($action === 'session') { + $sessionId = trim((string) ($_GET['session_id'] ?? '')); + if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); + } + $row = LiveCastRepository::publicSession($sessionId); + if ($row === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true, 'session' => $row]); + } + json_out(['ok' => false, 'error' => 'unknown_action'], 400); +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method_not_allowed'], 405); +} + +if ($action === 'create_intent') { + $row = LiveCastRepository::createIntent($body, $actor); + if ($row === []) { + json_out(['ok' => false, 'error' => 'create_failed'], 500); + } + json_out(['ok' => true, 'session' => $row]); +} + +if ($action === 'heartbeat') { + $sessionId = trim((string) ($body['session_id'] ?? '')); + if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); + } + $row = LiveCastRepository::heartbeat($sessionId, $body, $actor); + if ($row === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true, 'session' => $row]); +} + +if ($action === 'join_event') { + $sessionId = trim((string) ($body['session_id'] ?? '')); + if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); + } + $ok = LiveCastRepository::registerJoin($sessionId, $body, $actor); + if (!$ok) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true]); +} + +json_out(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/public/api/live_cast_signaling.php b/public/api/live_cast_signaling.php new file mode 100644 index 0000000..da26ea9 --- /dev/null +++ b/public/api/live_cast_signaling.php @@ -0,0 +1,84 @@ + false, 'error' => 'invalid_json'], 400); + } + $sessionId = trim((string) ($body['session_id'] ?? $sessionId)); +} + +if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); +} + +$session = LiveCastRepository::sessionById($sessionId); +if ($session === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); +} + +$platform = strtolower((string) ($session['platform'] ?? '')); +$status = strtolower((string) ($session['status'] ?? '')); +$isPublic = str_starts_with($platform, 'education') + || in_array($status, ['intent', 'live'], true); + +if (!$isPublic) { + Auth::check(); +} + +if ($method === 'GET') { + $action = trim((string) ($_GET['action'] ?? 'poll')); + if ($action === 'latest') { + $latest = LiveCastSignalingRepository::latest( + $sessionId, + $msgType !== '' ? $msgType : 'offer', + $fromRole !== '' ? $fromRole : null + ); + json_out(['ok' => true, 'message' => $latest]); + } + $messages = LiveCastSignalingRepository::poll( + $sessionId, + $sinceId, + $msgType !== '' ? $msgType : null, + $fromRole !== '' ? $fromRole : null + ); + $lastId = $sinceId; + foreach ($messages as $m) { + $lastId = max($lastId, (int) ($m['id'] ?? 0)); + } + json_out(['ok' => true, 'messages' => $messages, 'since_id' => $lastId]); +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method_not_allowed'], 405); +} + +$action = trim((string) ($body['action'] ?? 'post')); +if ($action !== 'post') { + json_out(['ok' => false, 'error' => 'unknown_action'], 400); +} + +$peerRole = trim((string) ($body['peer_role'] ?? 'caster')); +$type = trim((string) ($body['msg_type'] ?? '')); +$payload = $body['payload'] ?? null; +if (!is_array($payload)) { + $payload = ['sdp' => (string) ($body['sdp'] ?? ''), 'type' => (string) ($body['type'] ?? '')]; +} +if ($type === '') { + json_out(['ok' => false, 'error' => 'missing_msg_type'], 400); +} + +$id = LiveCastSignalingRepository::post($sessionId, $peerRole, $type, $payload); +json_out(['ok' => true, 'id' => $id]); diff --git a/public/api/report_tags.php b/public/api/report_tags.php new file mode 100644 index 0000000..db4b094 --- /dev/null +++ b/public/api/report_tags.php @@ -0,0 +1,73 @@ + false, 'error' => 'unauthorized'], 401); +} + +$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); +$id = (int) ($_GET['id'] ?? 0); + +if ($method === 'GET') { + if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); + } + $report = ReportRepository::getById($id); + if (!$report) { + json_out(['ok' => false, 'error' => 'not found'], 404); + } + json_out([ + 'ok' => true, + 'id' => $id, + 'tags' => $report['tags'] ?? [], + 'workflow' => TagCatalog::workflowPresets(), + 'presets' => TagCatalog::editorPresets(), + 'can_edit' => Auth::canEditTags((int) ($report['company_id'] ?? 0)), + ]); +} + +if ($method !== 'PUT' && $method !== 'POST') { + json_out(['ok' => false, 'error' => 'method not allowed'], 405); +} + +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} +$id = (int) ($data['id'] ?? $id); +if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); +} + +$tagsIn = $data['tags'] ?? []; +if (!is_array($tagsIn)) { + json_out(['ok' => false, 'error' => 'tags must be array'], 400); +} + +$report = ReportRepository::getById($id); +if (!$report) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} + +if (!Auth::canEditTags((int) ($report['company_id'] ?? 0))) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); +} + +try { + ReportRepository::setCustomTags($id, $tagsIn); + $updated = ReportRepository::getById($id); + json_out([ + 'ok' => true, + 'id' => $id, + 'tags' => $updated['tags'] ?? [], + ]); +} catch (Throwable $e) { + error_log('report_tags: ' . $e->getMessage()); + $out = ['ok' => false, 'error' => 'save failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + } + json_out($out, 500); +} diff --git a/public/api/report_viewed.php b/public/api/report_viewed.php new file mode 100644 index 0000000..fa18d8f --- /dev/null +++ b/public/api/report_viewed.php @@ -0,0 +1,25 @@ + false, 'error' => 'unauthorized'], 401); +} +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + json_out(['ok' => false, 'error' => 'POST required'], 405); +} + +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; +$id = (int) ($data['id'] ?? 0); +if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); +} + +try { + ReportRepository::markViewed($id, (int) Auth::user()['id']); + json_out(['ok' => true, 'id' => $id]); +} catch (Throwable $e) { + error_log('report_viewed: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'failed'], 500); +} diff --git a/public/api/reports.php b/public/api/reports.php new file mode 100644 index 0000000..72182fc --- /dev/null +++ b/public/api/reports.php @@ -0,0 +1,87 @@ + false, 'error' => 'unauthorized'], 401); +} + +$grouped = isset($_GET['group']) && $_GET['group'] === '1'; +$page = max(1, (int) ($_GET['page'] ?? 1)); +$perPage = (int) ($_GET['per_page'] ?? 50); +$allowedPerPage = [25, 50, 75, 100, 200]; +if (!in_array($perPage, $allowedPerPage, true)) { + $perPage = 50; +} +$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'priority')); +$dir = (string) ($_GET['dir'] ?? 'desc'); +$sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0)); +$similarTo = max(0, (int) ($_GET['similar_to'] ?? 0)); +$searchQ = trim((string) ($_GET['q'] ?? '')); +$suggest = isset($_GET['suggest']) && $_GET['suggest'] === '1'; + +$tagIds = request_tag_filter_ids(); +$tagMode = request_tag_filter_mode(); + +$filters = []; +if ($tagIds !== []) { + $filters['tags'] = $tagIds; + $filters['tag_mode'] = $tagMode; +} +$device = trim((string) ($_GET['filter_device'] ?? '')); +if ($device !== '') { + $filters['device'] = $device; +} +$abi = trim((string) ($_GET['filter_abi'] ?? '')); +if ($abi !== '') { + $filters['abi'] = $abi; +} +$osSdk = max(0, (int) ($_GET['filter_os_sdk'] ?? 0)); +if ($osSdk > 0) { + $filters['os_sdk'] = $osSdk; +} +$osRelease = trim((string) ($_GET['filter_os_release'] ?? '')); +if ($osRelease !== '') { + $filters['os_release'] = $osRelease; +} +$appVersion = trim((string) ($_GET['filter_app_version'] ?? '')); +if ($appVersion !== '') { + $filters['app_version'] = $appVersion; +} +$build = trim((string) ($_GET['filter_build'] ?? '')); +if ($build !== '') { + $filters['build'] = $build; +} + +try { + if ($suggest) { + json_out([ + 'ok' => true, + 'suggestions' => ReportRepository::searchSuggestions($searchQ), + 'q' => $searchQ, + ]); + } + if ($searchQ !== '') { + $data = ReportRepository::search($searchQ, $page, $perPage, $sinceMs); + } elseif ($similarTo > 0) { + $data = ReportRepository::listSimilar($similarTo, $page, $perPage, $sinceMs); + } elseif ($filters !== []) { + if ($tagIds !== []) { + $grouped = false; + } + $data = ReportRepository::listFiltered($filters, $page, $perPage, $sort, $dir, $sinceMs); + } else { + $data = ReportRepository::listPage($grouped, $page, $perPage, $sort, $dir, $sinceMs); + } + json_out(['ok' => true, 'tag_filter' => $tagIds, 'tag_mode' => $tagMode] + $data); +} catch (Throwable $e) { + error_log('reports api: ' . $e->getMessage()); + $out = ['ok' => false, 'error' => 'list failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + if (str_contains($e->getMessage(), '2002')) { + $out['hint'] .= ' — use db.mysql.socket (/run/mysqld/mysqld.sock on Alpine); TCP 3306 is closed'; + } + } + json_out($out, 500); +} diff --git a/public/api/sfu_health.php b/public/api/sfu_health.php new file mode 100644 index 0000000..39ac4eb --- /dev/null +++ b/public/api/sfu_health.php @@ -0,0 +1,18 @@ + true, + 'sfu' => [ + 'enabled' => $enabled, + 'status' => $enabled ? 'preview' : 'disabled', + 'signaling_url' => $enabled ? (string) ($sfu['signaling_url'] ?? '') : '', + 'janus_url' => $enabled ? (string) ($sfu['janus_url'] ?? '') : '', + ], +], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/public/api/tag_catalog.php b/public/api/tag_catalog.php new file mode 100644 index 0000000..4871c4c --- /dev/null +++ b/public/api/tag_catalog.php @@ -0,0 +1,15 @@ + false, 'error' => 'unauthorized'], 401); +} + +json_out([ + 'ok' => true, + 'workflow' => TagCatalog::workflowPresets(), + 'presets' => TagCatalog::editorPresets(), + 'filter_mode_default' => 'and', + 'filter_mode_help' => 'AND = report has all listed tags; OR = any listed tag', +]); diff --git a/public/api/upload.php b/public/api/upload.php new file mode 100644 index 0000000..adf85b8 --- /dev/null +++ b/public/api/upload.php @@ -0,0 +1,53 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 25 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 edbab2563cb9d5ba1c9a6a6272071ac12efa782274c7d1626f088a951033d5a6 + */ +declare(strict_types=1); +require_once __DIR__ . '/../../src/bootstrap.php'; + +$raw = read_crash_upload_body(); +if ($raw === null) { + json_out(['ok' => false, 'error' => 'empty body'], 400); +} +if ($raw === false) { + json_out(['ok' => false, 'error' => 'invalid gzip body'], 400); +} +$data = json_decode($raw, true); +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} +if ((int) ($data['schema_version'] ?? 0) !== 1) { + json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400); +} +try { + ReportRepository::insert($data); + json_out(['ok' => true, 'report_id' => $data['report_id'] ?? null]); +} catch (PDOException $e) { + $msg = $e->getMessage(); + if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) { + json_out(['ok' => false, 'error' => 'duplicate report_id'], 409); + } + error_log('crash upload PDO: ' . $msg); + log_crash_event('upload', 'PDO: ' . $msg); + $out = ['ok' => false, 'error' => 'store failed']; + if (cfg('debug')) { + $out['hint'] = $msg; + } + json_out($out, 500); +} catch (Throwable $e) { + error_log('crash upload: ' . $e->getMessage()); + log_crash_event('upload', $e->getMessage()); + $out = ['ok' => false, 'error' => 'store failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + } + json_out($out, 500); +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..922b7c2 --- /dev/null +++ b/public/index.php @@ -0,0 +1,2 @@ +exec( + "CREATE TABLE live_cast_sessions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + owner_user_id INT UNSIGNED NULL, + owner_username VARCHAR(64) NULL, + device_id VARCHAR(128) NULL, + platform VARCHAR(24) NOT NULL DEFAULT 'android', + status ENUM('intent','live','closed','expired') NOT NULL DEFAULT 'intent', + started_at_ms BIGINT NOT NULL, + last_heartbeat_ms BIGINT NOT NULL, + ended_at_ms BIGINT NULL, + max_duration_s INT NOT NULL DEFAULT 300, + join_short_url TEXT NULL, + join_qr_url TEXT NULL, + join_slug VARCHAR(32) NULL, + direct_short_url TEXT NULL, + direct_qr_url TEXT NULL, + direct_slug VARCHAR(32) NULL, + codec_video VARCHAR(32) NULL, + codec_audio VARCHAR(32) NULL, + bw_mode VARCHAR(24) NULL, + metadata_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_live_cast_session_id (session_id), + KEY idx_live_cast_company_status (company_id, status), + KEY idx_live_cast_last_hb (last_heartbeat_ms), + KEY idx_live_cast_owner (owner_user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + $pdo->exec( + "CREATE TABLE live_cast_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + company_id INTEGER NOT NULL DEFAULT 1, + owner_user_id INTEGER NULL, + owner_username TEXT NULL, + device_id TEXT NULL, + platform TEXT NOT NULL DEFAULT 'android', + status TEXT NOT NULL DEFAULT 'intent', + started_at_ms INTEGER NOT NULL, + last_heartbeat_ms INTEGER NOT NULL, + ended_at_ms INTEGER NULL, + max_duration_s INTEGER NOT NULL DEFAULT 300, + join_short_url TEXT NULL, + join_qr_url TEXT NULL, + join_slug TEXT NULL, + direct_short_url TEXT NULL, + direct_qr_url TEXT NULL, + direct_slug TEXT NULL, + codec_video TEXT NULL, + codec_audio TEXT NULL, + bw_mode TEXT NULL, + metadata_json TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + } + } + + if (!Database::tableExists($pdo, 'live_cast_join_events')) { + if (Database::isMysql()) { + $pdo->exec( + "CREATE TABLE live_cast_join_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + event_type ENUM('open','join','leave') NOT NULL DEFAULT 'open', + platform VARCHAR(24) NOT NULL DEFAULT 'unknown', + channel VARCHAR(24) NOT NULL DEFAULT 'short_link', + direct TINYINT(1) NOT NULL DEFAULT 0, + user_id INT UNSIGNED NULL, + anon_id VARCHAR(96) NULL, + user_agent VARCHAR(255) NULL, + ip_hash CHAR(64) NULL, + created_at_ms BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_live_join_session_time (session_id, created_at_ms), + KEY idx_live_join_company_time (company_id, created_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + $pdo->exec( + "CREATE TABLE live_cast_join_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + company_id INTEGER NOT NULL DEFAULT 1, + event_type TEXT NOT NULL DEFAULT 'open', + platform TEXT NOT NULL DEFAULT 'unknown', + channel TEXT NOT NULL DEFAULT 'short_link', + direct INTEGER NOT NULL DEFAULT 0, + user_id INTEGER NULL, + anon_id TEXT NULL, + user_agent TEXT NULL, + ip_hash TEXT NULL, + created_at_ms INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + } + } + } + + private static function ensureIndexes(PDO $pdo): void { + if (Database::isMysql()) { + return; + } + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_company_status ON live_cast_sessions (company_id, status)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_last_hb ON live_cast_sessions (last_heartbeat_ms)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_session_time ON live_cast_join_events (session_id, created_at_ms)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_company_time ON live_cast_join_events (company_id, created_at_ms)'); + } + + /** @return array */ + public static function createIntent(array $input, array $actor): array { + self::ensureSchema(); + self::expireStale(); + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $userId = (int) ($actor['id'] ?? 0); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $ownerUsername = trim((string) ($actor['username'] ?? '')); + $platform = strtolower(trim((string) ($input['platform'] ?? 'android'))); + if ($platform === '') { + $platform = 'android'; + } + $deviceId = trim((string) ($input['device_id'] ?? '')); + $maxDuration = (int) ($input['max_duration_s'] ?? 300); + $maxDuration = max(60, min(1800, $maxDuration)); + if (str_starts_with($platform, 'education')) { + $maxDuration = min($maxDuration, 300); + } + + $sessionId = self::newSessionId($now, $companyId, $userId); + $joinLongUrl = self::joinLongUrl($sessionId, false); + $directLongUrl = self::joinLongUrl($sessionId, true); + + $links = self::makeShortLinks($joinLongUrl, $directLongUrl); + $meta = is_array($input['meta'] ?? null) ? $input['meta'] : []; + + $stmt = $pdo->prepare( + 'INSERT INTO live_cast_sessions ( + session_id, company_id, owner_user_id, owner_username, device_id, platform, status, + started_at_ms, last_heartbeat_ms, max_duration_s, + join_short_url, join_qr_url, join_slug, direct_short_url, direct_qr_url, direct_slug, + codec_video, codec_audio, bw_mode, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $sessionId, + $companyId, + $userId > 0 ? $userId : null, + $ownerUsername !== '' ? $ownerUsername : null, + $deviceId !== '' ? $deviceId : null, + $platform, + 'intent', + $now, + $now, + $maxDuration, + $links['join_short_url'], + $links['join_qr_url'], + $links['join_slug'], + $links['direct_short_url'], + $links['direct_qr_url'], + $links['direct_slug'], + trim((string) ($input['codec_video'] ?? '')) ?: null, + trim((string) ($input['codec_audio'] ?? '')) ?: null, + trim((string) ($input['bw_mode'] ?? '')) ?: null, + safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ]); + + return self::sessionById($sessionId) ?? []; + } + + /** @return array|null */ + public static function heartbeat(string $sessionId, array $input, array $actor): ?array { + self::ensureSchema(); + self::expireStale(); + $sessionId = trim($sessionId); + if ($sessionId === '') { + return null; + } + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $status = strtolower(trim((string) ($input['status'] ?? 'active'))); + if ($status !== 'ended' && $status !== 'active') { + $status = 'active'; + } + $nextState = $status === 'ended' ? 'closed' : 'live'; + $stmt = $pdo->prepare( + 'UPDATE live_cast_sessions + SET status = ?, last_heartbeat_ms = ?, ended_at_ms = CASE WHEN ? = ? THEN ? ELSE ended_at_ms END, + codec_video = COALESCE(NULLIF(?, \'\'), codec_video), + codec_audio = COALESCE(NULLIF(?, \'\'), codec_audio), + bw_mode = COALESCE(NULLIF(?, \'\'), bw_mode) + WHERE session_id = ? AND company_id = ?' + ); + $stmt->execute([ + $nextState, + $now, + $nextState, + 'closed', + $now, + trim((string) ($input['codec_video'] ?? '')), + trim((string) ($input['codec_audio'] ?? '')), + trim((string) ($input['bw_mode'] ?? '')), + $sessionId, + $companyId, + ]); + if ($stmt->rowCount() < 1) { + return null; + } + if (isset($input['stats']) && is_array($input['stats'])) { + $row = self::sessionById($sessionId); + $meta = []; + if ($row !== null && !empty($row['metadata_json'])) { + $decoded = json_decode((string) $row['metadata_json'], true); + if (is_array($decoded)) { + $meta = $decoded; + } + } + $meta['last_stats'] = $input['stats']; + $meta['last_stats_ms'] = $now; + $upd = $pdo->prepare('UPDATE live_cast_sessions SET metadata_json = ? WHERE session_id = ?'); + $upd->execute([ + safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + $sessionId, + ]); + } + return self::sessionById($sessionId); + } + + /** @return list> */ + public static function activeSessions(array $actor, ?string $platform = null): array { + self::ensureSchema(); + self::expireStale(); + $pdo = Database::pdo(); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $where = ['company_id = ?', "status IN ('intent','live')"]; + $params = [$companyId]; + if ($platform !== null && trim($platform) !== '') { + $where[] = 'platform = ?'; + $params[] = strtolower(trim($platform)); + } + $sql = 'SELECT session_id, owner_user_id, owner_username, device_id, platform, status, + started_at_ms, last_heartbeat_ms, max_duration_s, ended_at_ms, + join_short_url, join_qr_url, direct_short_url, direct_qr_url, + codec_video, codec_audio, bw_mode + FROM live_cast_sessions + WHERE ' . implode(' AND ', $where) . ' + ORDER BY last_heartbeat_ms DESC LIMIT 200'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = self::normalizeSessionRow($row); + } + return $out; + } + + /** @return array */ + public static function analytics(int $days, array $actor): array { + self::ensureSchema(); + self::expireStale(); + $days = max(1, min(90, $days)); + $startMs = (int) ((time() - ($days * 86400)) * 1000); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $pdo = Database::pdo(); + + $castsPerDay = self::countByDay($pdo, 'live_cast_sessions', 'started_at_ms', $startMs, $companyId); + $avgDurationPerDay = self::avgDurationByDay($pdo, $startMs, $companyId); + $platformMix = self::breakdown($pdo, 'live_cast_sessions', 'platform', $startMs, $companyId); + $videoCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_video', $startMs, $companyId); + $audioCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_audio', $startMs, $companyId); + $bwModeMix = self::breakdown($pdo, 'live_cast_sessions', 'bw_mode', $startMs, $companyId); + $joinsPerPlatform = self::breakdown($pdo, 'live_cast_join_events', 'platform', $startMs, $companyId); + + $topUsersStmt = $pdo->prepare( + 'SELECT COALESCE(NULLIF(owner_username, \'\'), \'unknown\') AS u, COUNT(*) AS c + FROM live_cast_sessions + WHERE company_id = ? AND started_at_ms >= ? + GROUP BY u ORDER BY c DESC LIMIT 20' + ); + $topUsersStmt->execute([$companyId, $startMs]); + $topUsers = []; + foreach ($topUsersStmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $topUsers[] = ['label' => (string) ($row['u'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)]; + } + + return [ + 'window_days' => $days, + 'casts_per_day' => $castsPerDay, + 'avg_length_s_per_day' => $avgDurationPerDay, + 'platform_mix' => $platformMix, + 'joins_platform_mix' => $joinsPerPlatform, + 'video_codec_mix' => $videoCodecMix, + 'audio_codec_mix' => $audioCodecMix, + 'bw_mode_mix' => $bwModeMix, + 'top_users' => $topUsers, + ]; + } + + public static function registerJoin(string $sessionId, array $payload, array $actor): bool { + self::ensureSchema(); + $sessionId = trim($sessionId); + if ($sessionId === '') { + return false; + } + $session = self::sessionById($sessionId); + if ($session === null) { + return false; + } + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $companyId = (int) ($session['company_id'] ?? Rbac::defaultCompanyId()); + $eventType = strtolower(trim((string) ($payload['event_type'] ?? 'open'))); + if (!in_array($eventType, ['open', 'join', 'leave'], true)) { + $eventType = 'open'; + } + $platform = strtolower(trim((string) ($payload['platform'] ?? 'unknown'))); + if ($platform === '') { + $platform = 'unknown'; + } + $channel = strtolower(trim((string) ($payload['channel'] ?? 'short_link'))); + if ($channel === '') { + $channel = 'short_link'; + } + $direct = !empty($payload['direct']) ? 1 : 0; + $userId = (int) ($actor['id'] ?? 0); + $anon = trim((string) ($payload['anon_id'] ?? '')); + $ua = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); + $ip = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '')); + $ipHash = $ip !== '' ? hash('sha256', $ip . '|ac-live-cast') : null; + + $stmt = $pdo->prepare( + 'INSERT INTO live_cast_join_events + (session_id, company_id, event_type, platform, channel, direct, user_id, anon_id, user_agent, ip_hash, created_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $sessionId, + $companyId, + $eventType, + $platform, + $channel, + $direct, + $userId > 0 ? $userId : null, + $anon !== '' ? $anon : null, + $ua !== '' ? substr($ua, 0, 255) : null, + $ipHash, + $now, + ]); + return true; + } + + /** @return array|null */ + public static function sessionById(string $sessionId): ?array { + self::ensureSchema(); + $stmt = Database::pdo()->prepare( + 'SELECT * FROM live_cast_sessions WHERE session_id = ? LIMIT 1' + ); + $stmt->execute([$sessionId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($row)) { + return null; + } + return self::normalizeSessionRow($row); + } + + private static function expireStale(): void { + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $staleBefore = $now - 60_000; + $sql = "UPDATE live_cast_sessions + SET status = 'expired', ended_at_ms = COALESCE(ended_at_ms, ?) + WHERE status IN ('intent','live') + AND last_heartbeat_ms < ?"; + $stmt = $pdo->prepare($sql); + $stmt->execute([$now, $staleBefore]); + } + + /** @return array */ + private static function normalizeSessionRow(array $row): array { + return [ + 'session_id' => (string) ($row['session_id'] ?? ''), + 'company_id' => (int) ($row['company_id'] ?? 0), + 'owner_user_id' => isset($row['owner_user_id']) ? (int) $row['owner_user_id'] : null, + 'owner_username' => (string) ($row['owner_username'] ?? ''), + 'device_id' => (string) ($row['device_id'] ?? ''), + 'platform' => (string) ($row['platform'] ?? ''), + 'status' => (string) ($row['status'] ?? ''), + 'started_at_ms' => (int) ($row['started_at_ms'] ?? 0), + 'last_heartbeat_ms' => (int) ($row['last_heartbeat_ms'] ?? 0), + 'ended_at_ms' => isset($row['ended_at_ms']) ? (int) $row['ended_at_ms'] : null, + 'max_duration_s' => (int) ($row['max_duration_s'] ?? 0), + 'join_short_url' => (string) ($row['join_short_url'] ?? ''), + 'join_qr_url' => (string) ($row['join_qr_url'] ?? ''), + 'direct_short_url' => (string) ($row['direct_short_url'] ?? ''), + 'direct_qr_url' => (string) ($row['direct_qr_url'] ?? ''), + 'codec_video' => (string) ($row['codec_video'] ?? ''), + 'codec_audio' => (string) ($row['codec_audio'] ?? ''), + 'bw_mode' => (string) ($row['bw_mode'] ?? ''), + 'metadata_json' => (string) ($row['metadata_json'] ?? ''), + ]; + } + + private static function newSessionId(int $nowMs, int $companyId, int $userId): string { + $seed = $nowMs . ':' . $companyId . ':' . $userId . ':' . bin2hex(random_bytes(6)); + return 'live-' . substr(hash('sha256', $seed), 0, 20); + } + + private static function joinLongUrl(string $sessionId, bool $direct): string { + $base = rtrim((string) cfg('base_path', '/app/androidcast_project/issues'), '/'); + if (str_ends_with($base, '/crashes')) { + $base = substr($base, 0, -strlen('/crashes')) . '/issues'; + } + $qs = 'session_id=' . rawurlencode($sessionId); + if ($direct) { + $qs .= '&direct=1'; + } + return 'https://apps.f0xx.org' . $base . '/live/join?' . $qs; + } + + /** @return array */ + private static function makeShortLinks(string $joinLongUrl, string $directLongUrl): array { + $out = [ + 'join_short_url' => null, + 'join_qr_url' => null, + 'join_slug' => null, + 'direct_short_url' => null, + 'direct_qr_url' => null, + 'direct_slug' => null, + ]; + if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) { + return $out; + } + $bearers = ShortLinksRepository::listBearers(); + if ($bearers === []) { + return $out; + } + $bearerId = (int) ($bearers[0]['id'] ?? 0); + if ($bearerId <= 0) { + return $out; + } + $join = ShortLinksRepository::createLink($bearerId, $joinLongUrl, 3600); + if (!empty($join['ok'])) { + $out['join_short_url'] = (string) ($join['short_url'] ?? ''); + $out['join_qr_url'] = (string) ($join['qr_url'] ?? ''); + $out['join_slug'] = (string) ($join['slug'] ?? ''); + } + $direct = ShortLinksRepository::createLink($bearerId, $directLongUrl, 3600); + if (!empty($direct['ok'])) { + $out['direct_short_url'] = (string) ($direct['short_url'] ?? ''); + $out['direct_qr_url'] = (string) ($direct['qr_url'] ?? ''); + $out['direct_slug'] = (string) ($direct['slug'] ?? ''); + } + return $out; + } + + /** @return list */ + private static function countByDay(PDO $pdo, string $table, string $msColumn, int $startMs, int $companyId): array { + $day = Database::isMysql() + ? 'DATE(FROM_UNIXTIME(FLOOR(' . $msColumn . ' / 1000)))' + : "strftime('%Y-%m-%d', " . $msColumn . " / 1000, 'unixepoch')"; + $stmt = $pdo->prepare( + 'SELECT ' . $day . ' AS d, COUNT(*) AS c FROM ' . $table . ' + WHERE company_id = ? AND ' . $msColumn . ' >= ? + GROUP BY d ORDER BY d' + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => (int) ($row['c'] ?? 0)]; + } + return $out; + } + + /** @return list */ + private static function avgDurationByDay(PDO $pdo, int $startMs, int $companyId): array { + $day = Database::isMysql() + ? 'DATE(FROM_UNIXTIME(FLOOR(started_at_ms / 1000)))' + : "strftime('%Y-%m-%d', started_at_ms / 1000, 'unixepoch')"; + $stmt = $pdo->prepare( + 'SELECT ' . $day . ' AS d, + AVG(CASE WHEN ended_at_ms IS NOT NULL AND ended_at_ms >= started_at_ms + THEN (ended_at_ms - started_at_ms) / 1000.0 ELSE 0 END) AS v + FROM live_cast_sessions + WHERE company_id = ? AND started_at_ms >= ? + GROUP BY d ORDER BY d' + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => round((float) ($row['v'] ?? 0), 1)]; + } + return $out; + } + + /** @return list> */ + public static function listSessions(int $days, array $actor, int $limit = 200): array { + self::ensureSchema(); + self::expireStale(); + $days = max(1, min(90, $days)); + $startMs = (int) ((time() - ($days * 86400)) * 1000); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $limit = max(1, min(500, $limit)); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'SELECT s.session_id, s.owner_user_id, s.owner_username, s.device_id, s.platform, s.status, + s.started_at_ms, s.last_heartbeat_ms, s.ended_at_ms, s.max_duration_s, + s.join_short_url, s.direct_short_url, s.codec_video, s.codec_audio, s.bw_mode, + (SELECT COUNT(*) FROM live_cast_join_events j + WHERE j.session_id = s.session_id AND j.event_type IN (\'open\', \'join\')) AS join_opens + FROM live_cast_sessions s + WHERE s.company_id = ? AND s.started_at_ms >= ? + ORDER BY s.last_heartbeat_ms DESC + LIMIT ' . (int) $limit + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $normalized = self::normalizeSessionRow($row); + $normalized['join_opens'] = (int) ($row['join_opens'] ?? 0); + $out[] = $normalized; + } + return $out; + } + + /** @return array|null */ + public static function publicSession(string $sessionId): ?array { + $row = self::sessionById($sessionId); + if ($row === null) { + return null; + } + return [ + 'session_id' => $row['session_id'], + 'owner_username' => $row['owner_username'], + 'platform' => $row['platform'], + 'status' => $row['status'], + 'started_at_ms' => $row['started_at_ms'], + 'last_heartbeat_ms' => $row['last_heartbeat_ms'], + 'ended_at_ms' => $row['ended_at_ms'], + 'max_duration_s' => $row['max_duration_s'], + 'codec_video' => $row['codec_video'], + 'codec_audio' => $row['codec_audio'], + 'bw_mode' => $row['bw_mode'], + 'join_short_url' => $row['join_short_url'] ?? '', + 'join_qr_url' => $row['join_qr_url'] ?? '', + 'signaling_api' => Auth::basePath() . '/api/live_cast_signaling.php', + ]; + } + + /** @return list */ + private static function breakdown(PDO $pdo, string $table, string $column, int $startMs, int $companyId): array { + $allowedTables = ['live_cast_sessions', 'live_cast_join_events']; + if (!in_array($table, $allowedTables, true)) { + return []; + } + $allowedCols = ['platform', 'codec_video', 'codec_audio', 'bw_mode']; + if ($table === 'live_cast_join_events') { + $allowedCols = ['platform']; + } + if (!in_array($column, $allowedCols, true)) { + return []; + } + $timeColumn = $table === 'live_cast_sessions' ? 'started_at_ms' : 'created_at_ms'; + $stmt = $pdo->prepare( + 'SELECT COALESCE(NULLIF(' . $column . ', \'\'), \'unknown\') AS k, COUNT(*) AS c + FROM ' . $table . ' + WHERE company_id = ? AND ' . $timeColumn . ' >= ? + GROUP BY k ORDER BY c DESC' + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)]; + } + return $out; + } +} + diff --git a/src/LiveCastSignalingRepository.php b/src/LiveCastSignalingRepository.php new file mode 100644 index 0000000..219eddb --- /dev/null +++ b/src/LiveCastSignalingRepository.php @@ -0,0 +1,134 @@ +exec( + "CREATE TABLE live_cast_signal_messages ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + peer_role VARCHAR(16) NOT NULL DEFAULT 'caster', + msg_type VARCHAR(24) NOT NULL, + payload_json LONGTEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + KEY idx_live_signal_session_id (session_id, id), + KEY idx_live_signal_session_type (session_id, msg_type, id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + $pdo->exec( + "CREATE TABLE live_cast_signal_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + peer_role TEXT NOT NULL DEFAULT 'caster', + msg_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at_ms INTEGER NOT NULL + )" + ); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_signal_session_id ON live_cast_signal_messages (session_id, id)'); + } + } + }); + self::$schemaEnsured = true; + } + + /** @param array $payload */ + public static function post(string $sessionId, string $peerRole, string $msgType, array $payload): int { + self::ensureSchema(); + $sessionId = trim($sessionId); + $peerRole = strtolower(trim($peerRole)); + $msgType = strtolower(trim($msgType)); + if ($sessionId === '' || $msgType === '') { + return 0; + } + if (!in_array($peerRole, ['caster', 'viewer', 'mobile'], true)) { + $peerRole = 'caster'; + } + $now = (int) floor(microtime(true) * 1000); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO live_cast_signal_messages (session_id, peer_role, msg_type, payload_json, created_at_ms) + VALUES (?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $sessionId, + $peerRole, + $msgType, + safe_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + $now, + ]); + return (int) $pdo->lastInsertId(); + } + + /** @return list> */ + public static function poll(string $sessionId, int $sinceId, ?string $msgType = null, ?string $fromRole = null): array { + self::ensureSchema(); + $sessionId = trim($sessionId); + if ($sessionId === '') { + return []; + } + $sinceId = max(0, $sinceId); + $where = ['session_id = ?', 'id > ?']; + $params = [$sessionId, $sinceId]; + if ($msgType !== null && trim($msgType) !== '') { + $where[] = 'msg_type = ?'; + $params[] = strtolower(trim($msgType)); + } + if ($fromRole !== null && trim($fromRole) !== '') { + $where[] = 'peer_role = ?'; + $params[] = strtolower(trim($fromRole)); + } + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'SELECT id, session_id, peer_role, msg_type, payload_json, created_at_ms + FROM live_cast_signal_messages + WHERE ' . implode(' AND ', $where) . ' + ORDER BY id ASC LIMIT 200' + ); + $stmt->execute($params); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $payload = json_decode((string) ($row['payload_json'] ?? ''), true); + $out[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'session_id' => (string) ($row['session_id'] ?? ''), + 'peer_role' => (string) ($row['peer_role'] ?? ''), + 'msg_type' => (string) ($row['msg_type'] ?? ''), + 'payload' => is_array($payload) ? $payload : [], + 'created_at_ms' => (int) ($row['created_at_ms'] ?? 0), + ]; + } + return $out; + } + + /** @return array|null */ + public static function latest(string $sessionId, string $msgType, ?string $fromRole = null): ?array { + self::ensureSchema(); + $rows = self::poll($sessionId, 0, $msgType, $fromRole); + if ($rows === []) { + return null; + } + return $rows[count($rows) - 1]; + } + + public static function purgeSession(string $sessionId): void { + self::ensureSchema(); + $sessionId = trim($sessionId); + if ($sessionId === '') { + return; + } + $stmt = Database::pdo()->prepare('DELETE FROM live_cast_signal_messages WHERE session_id = ?'); + $stmt->execute([$sessionId]); + } +} diff --git a/src/ReportRepository.php b/src/ReportRepository.php new file mode 100644 index 0000000..dc5e9ec --- /dev/null +++ b/src/ReportRepository.php @@ -0,0 +1,707 @@ +prepare( + 'INSERT INTO reports (company_id, device_id, report_id, fingerprint, crash_type, generated_at_ms, + received_at_ms, device_model, app_version, payload_json, tags_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $companyId, + $deviceId > 0 ? $deviceId : null, + $payload['report_id'] ?? uniqid('rpt_', true), + $payload['fingerprint'] ?? 'unknown', + $payload['crash_type'] ?? 'java', + (int) ($payload['generated_at_epoch_ms'] ?? 0), + (int) round(microtime(true) * 1000), + $device['model'] ?? null, + $app['version_name'] ?? null, + $json, + $tags ?: '[]', + ]); + } + + public static function listReports(bool $grouped): array { + return self::listPage($grouped, 1, 500, 'generated_at_ms', 'desc')['items']; + } + + public static function listPage( + bool $grouped, + int $page, + int $perPage, + string $sort, + string $dir, + int $sinceMs = 0, + array $tagFilters = [], + string $tagMode = 'and' + ): array { + if ($tagFilters !== []) { + return self::listFiltered( + ['tags' => $tagFilters, 'tag_mode' => $tagMode], + $page, + $perPage, + $sort, + $dir, + $sinceMs + ); + } + $pdo = Database::pdo(); + $page = max(1, $page); + $perPage = max(1, min(200, $perPage)); + $dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC'; + $offset = ($page - 1) * $perPage; + $userId = (int) (Auth::user()['id'] ?? 0); + + if ($grouped) { + $sortCol = in_array($sort, self::GROUP_SORT, true) ? $sort : 'cnt'; + $gWhere = ['1=1']; + $gParams = []; + self::appendCompanyScope('company_id', $gWhere, $gParams); + $gWhereSql = implode(' AND ', $gWhere); + $countStmt = $pdo->prepare( + "SELECT COUNT(*) FROM ( + SELECT 1 FROM reports WHERE $gWhereSql GROUP BY fingerprint, crash_type + ) t" + ); + $countStmt->execute($gParams); + $total = (int) $countStmt->fetchColumn(); + $sql = "SELECT fingerprint, crash_type, + MIN(device_model) AS device_model, + MAX(app_version) AS app_version, + COUNT(*) AS cnt, + MAX(generated_at_ms) AS last_generated_ms, + MAX(received_at_ms) AS last_received_ms + FROM reports + WHERE $gWhereSql + GROUP BY fingerprint, crash_type + ORDER BY $sortCol $dir + LIMIT $perPage OFFSET $offset"; + $stmt = $pdo->prepare($sql); + $stmt->execute($gParams); + $items = $stmt->fetchAll(); + foreach ($items as &$row) { + $row['fingerprint_cnt'] = (int) ($row['cnt'] ?? 0); + $row['fingerprint_recent_cnt'] = $sinceMs > 0 && (int) ($row['last_received_ms'] ?? 0) > $sinceMs + ? (int) $row['cnt'] : 0; + $row['rating'] = report_rating_score((int) $row['cnt'], (int) $row['fingerprint_recent_cnt'], (int) $row['cnt']); + $row['os_name'] = ''; + $row['os_version'] = ''; + $row['brief'] = report_brief_lines($row, true); + } + unset($row); + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => $sortCol, + 'dir' => strtolower($dir), + 'grouped' => true, + ]; + } + + $sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority'; + if ($sortCol === 'rating') { + $sortCol = 'fingerprint_cnt'; + } + $orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir); + $where = ['1=1']; + $scopeParams = []; + self::appendCompanyScope('r.company_id', $where, $scopeParams); + $whereSql = implode(' AND ', $where); + $countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql"); + $countStmt->execute($scopeParams); + $total = (int) $countStmt->fetchColumn(); + $sql = 'SELECT ' . self::listSelectColumns() . ' + FROM reports r + ' . self::fingerprintStatsJoin() . ' + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE ' . $whereSql . ' + ORDER BY ' . $orderBy . ' + LIMIT ' . $perPage . ' OFFSET ' . $offset; + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge([$sinceMs, $userId], $scopeParams)); + $items = $stmt->fetchAll(); + self::finishListRows($items, $sinceMs, false); + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => $sort === 'priority' ? 'priority' : $sortCol, + 'dir' => strtolower($dir), + 'grouped' => false, + ]; + } + + /** + * @param array{similar_to?:int,device?:string,abi?:string,os_sdk?:int,os_release?:string} $filters + */ + public static function listFiltered( + array $filters, + int $page, + int $perPage, + string $sort, + string $dir, + int $sinceMs = 0 + ): array { + $pdo = Database::pdo(); + $page = max(1, $page); + $perPage = max(1, min(200, $perPage)); + $dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC'; + $userId = (int) (Auth::user()['id'] ?? 0); + $where = ['1=1']; + $params = []; + self::appendCompanyScope('r.company_id', $where, $params); + $device = trim((string) ($filters['device'] ?? '')); + if ($device !== '') { + $like = '%' . $device . '%'; + $where[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)'; + $params[] = $like; + $params[] = $like; + } + $abi = trim((string) ($filters['abi'] ?? '')); + if ($abi !== '') { + $where[] = 'r.payload_json LIKE ?'; + $params[] = '%' . $abi . '%'; + } + $sdk = (int) ($filters['os_sdk'] ?? 0); + if ($sdk > 0) { + $where[] = 'r.payload_json LIKE ?'; + $params[] = '%"sdk_int":' . $sdk . '%'; + } + $release = trim((string) ($filters['os_release'] ?? '')); + if ($release !== '') { + $where[] = '(r.payload_json LIKE ? OR r.payload_json LIKE ?)'; + $params[] = '%"release":"' . $release . '"%'; + $params[] = '%"release": "' . $release . '"%'; + } + $appVersion = trim((string) ($filters['app_version'] ?? '')); + if ($appVersion !== '') { + $like = '%' . $appVersion . '%'; + $where[] = '(r.app_version = ? OR r.app_version LIKE ? OR r.payload_json LIKE ?)'; + $params[] = $appVersion; + $params[] = $like; + $params[] = '%"version_name":"' . $appVersion . '"%'; + } + $build = trim((string) ($filters['build'] ?? '')); + if ($build !== '') { + $where[] = 'r.payload_json LIKE ?'; + $params[] = '%' . $build . '%'; + } + self::appendTagFilters($where, $params, $filters); + $whereSql = implode(' AND ', $where); + $countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql"); + $countStmt->execute($params); + $total = (int) $countStmt->fetchColumn(); + $sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority'; + if ($sortCol === 'rating') { + $sortCol = 'fingerprint_cnt'; + } + $orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir); + $offset = ($page - 1) * $perPage; + $sql = 'SELECT ' . self::listSelectColumns() . ' + FROM reports r + ' . self::fingerprintStatsJoin() . ' + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE ' . $whereSql . ' + ORDER BY ' . $orderBy . ' + LIMIT ' . $perPage . ' OFFSET ' . $offset; + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge([$sinceMs, $userId], $params)); + $items = $stmt->fetchAll(); + self::finishListRows($items, $sinceMs, false); + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => $sort === 'priority' ? 'priority' : $sortCol, + 'dir' => strtolower($dir), + 'grouped' => false, + 'filter' => $filters, + ]; + } + + public static function listSimilar( + int $anchorId, + int $page, + int $perPage, + int $sinceMs = 0 + ): array { + $anchor = self::getById($anchorId); + if ($anchor === null) { + return [ + 'items' => [], + 'total' => 0, + 'page' => 1, + 'per_page' => $perPage, + 'sort' => 'similarity', + 'dir' => 'desc', + 'grouped' => false, + 'similar_to' => $anchorId, + ]; + } + $pdo = Database::pdo(); + $page = max(1, $page); + $perPage = max(1, min(200, $perPage)); + $userId = (int) (Auth::user()['id'] ?? 0); + $payload = is_array($anchor['payload'] ?? null) ? $anchor['payload'] : []; + $keys = report_similarity_keys($payload); + $fp = $keys['fingerprint']; + $ct = $keys['crash_type']; + $familyLike = $keys['device_family'] !== '' ? '%' . $keys['device_family'] . '%' : null; + $excLike = $keys['exception'] !== '' ? '%' . $keys['exception'] . '%' : null; + $sigLike = $keys['signal'] !== '' ? '%' . $keys['signal'] . '%' : null; + + $where = ['r.id != ?']; + $params = [$anchorId]; + self::appendCompanyScope('r.company_id', $where, $params); + $or = []; + if ($fp !== '') { + $or[] = 'r.fingerprint = ?'; + $params[] = $fp; + } + if ($ct !== '') { + $or[] = 'r.crash_type = ?'; + $params[] = $ct; + } + if ($familyLike !== null) { + $or[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)'; + $params[] = $familyLike; + $params[] = $familyLike; + } + if ($excLike !== null) { + $or[] = 'r.payload_json LIKE ?'; + $params[] = $excLike; + } + if ($sigLike !== null) { + $or[] = 'r.payload_json LIKE ?'; + $params[] = $sigLike; + } + if ($or === []) { + $or[] = '1=1'; + } + $where[] = '(' . implode(' OR ', $or) . ')'; + $whereSql = implode(' AND ', $where); + + $sql = 'SELECT ' . self::listSelectColumns() . ' + FROM reports r + ' . self::fingerprintStatsJoin() . ' + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE ' . $whereSql . ' + ORDER BY r.received_at_ms DESC + LIMIT 1200'; + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge([$sinceMs, $userId], $params)); + $rows = $stmt->fetchAll(); + $scored = []; + foreach ($rows as $row) { + $p = json_decode($row['payload_json'] ?? '', true); + if (!is_array($p)) { + $p = []; + } + $score = report_similarity_score($keys, $row, $p); + if ($score <= 0) { + continue; + } + $row['similarity_score'] = $score; + $row['similarity_tier'] = $score >= 1000 ? 'exact' : ($score >= 200 ? 'strong' : ($score >= 80 ? 'likely' : 'guess')); + $scored[] = $row; + } + usort($scored, static function (array $a, array $b): int { + $sa = (int) ($a['similarity_score'] ?? 0); + $sb = (int) ($b['similarity_score'] ?? 0); + if ($sa !== $sb) { + return $sb <=> $sa; + } + return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0)); + }); + $total = count($scored); + $offset = ($page - 1) * $perPage; + $slice = array_values(array_slice($scored, $offset, $perPage)); + if ($page === 1) { + $anchorRow = null; + foreach ($scored as $row) { + if ((int) $row['id'] === $anchorId) { + $anchorRow = $row; + break; + } + } + if ($anchorRow === null) { + $device = is_array($payload['device'] ?? null) ? $payload['device'] : []; + $app = is_array($payload['app'] ?? null) ? $payload['app'] : []; + $anchorRow = [ + 'id' => $anchorId, + 'report_id' => $anchor['report_id'] ?? '', + 'fingerprint' => $anchor['fingerprint'] ?? ($payload['fingerprint'] ?? ''), + 'crash_type' => $anchor['crash_type'] ?? ($payload['crash_type'] ?? ''), + 'generated_at_ms' => (int) ($anchor['generated_at_ms'] ?? 0), + 'received_at_ms' => (int) ($anchor['received_at_ms'] ?? 0), + 'device_model' => $anchor['device_model'] ?? ($device['model'] ?? null), + 'app_version' => $anchor['app_version'] ?? ($app['version_name'] ?? null), + 'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}', + 'tags_json' => json_encode($anchor['tags'] ?? [], JSON_UNESCAPED_UNICODE) ?: '[]', + 'viewed' => !empty($anchor['viewed']) ? 1 : 0, + 'fingerprint_cnt' => 1, + 'fingerprint_recent_cnt' => 0, + ]; + } + $anchorRow['similarity_score'] = 10000; + $anchorRow['similarity_tier'] = 'anchor'; + $slice = array_values(array_filter( + $slice, + static fn (array $r): bool => (int) ($r['id'] ?? 0) !== $anchorId + )); + array_unshift($slice, $anchorRow); + if (count($slice) > $perPage) { + $slice = array_slice($slice, 0, $perPage); + } + } + self::finishListRows($slice, $sinceMs, false, true); + return [ + 'items' => $slice, + 'total' => $total + 1, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => 'similarity', + 'dir' => 'desc', + 'grouped' => false, + 'similar_to' => $anchorId, + ]; + } + + /** @return list */ + public static function searchSuggestions(string $q, int $limit = 10): array { + $tokens = search_query_tokens($q); + $prefix = $tokens[0] ?? mb_strtolower(trim($q)); + if (strlen($prefix) < 2) { + return []; + } + $pdo = Database::pdo(); + $where = ['1=1']; + $params = []; + self::appendCompanyScope('company_id', $where, $params); + $whereSql = implode(' AND ', $where); + $stmt = $pdo->prepare( + "SELECT payload_json, device_model, app_version, crash_type FROM reports + WHERE $whereSql ORDER BY received_at_ms DESC LIMIT 500" + ); + $stmt->execute($params); + $freq = []; + while ($row = $stmt->fetch()) { + $payload = json_decode($row['payload_json'] ?? '', true); + if (!is_array($payload)) { + $payload = []; + } + $java = is_array($payload['java'] ?? null) ? $payload['java'] : []; + $device = is_array($payload['device'] ?? null) ? $payload['device'] : []; + $candidates = array_filter([ + $java['exception'] ?? null, + $row['crash_type'] ?? null, + $row['device_model'] ?? null, + $device['manufacturer'] ?? null, + $device['model'] ?? null, + $row['app_version'] ?? null, + ]); + foreach ($candidates as $term) { + $term = trim((string) $term); + if ($term === '' || strlen($term) < 3) { + continue; + } + $low = mb_strtolower($term); + if (!str_contains($low, $prefix)) { + continue; + } + $freq[$term] = ($freq[$term] ?? 0) + 1; + } + if (!empty($java['message'])) { + if (preg_match_all('/[A-Za-z][A-Za-z0-9_]{3,}(?:Exception|Error)/', (string) $java['message'], $m)) { + foreach ($m[0] as $term) { + $low = mb_strtolower($term); + if (str_contains($low, $prefix)) { + $freq[$term] = ($freq[$term] ?? 0) + 2; + } + } + } + } + } + arsort($freq); + return array_slice(array_keys($freq), 0, max(1, min(20, $limit))); + } + + public static function search(string $q, int $page, int $perPage, int $sinceMs = 0): array { + $tokens = search_query_tokens($q); + if ($tokens === []) { + return [ + 'items' => [], + 'total' => 0, + 'page' => 1, + 'per_page' => $perPage, + 'sort' => 'relevance', + 'dir' => 'desc', + 'grouped' => false, + 'search' => $q, + ]; + } + $pdo = Database::pdo(); + $page = max(1, $page); + $perPage = max(1, min(200, $perPage)); + $userId = (int) (Auth::user()['id'] ?? 0); + $where = ['1=1']; + $params = []; + self::appendCompanyScope('r.company_id', $where, $params); + $orParts = []; + foreach ($tokens as $token) { + $like = '%' . $token . '%'; + $orParts[] = '(r.payload_json LIKE ? OR r.device_model LIKE ? OR r.app_version LIKE ? + OR r.fingerprint LIKE ? OR r.crash_type LIKE ?)'; + array_push($params, $like, $like, $like, $like, $like); + } + $where[] = '(' . implode(' OR ', $orParts) . ')'; + $whereSql = implode(' AND ', $where); + $sql = 'SELECT ' . self::listSelectColumns() . ' + FROM reports r + ' . self::fingerprintStatsJoin() . ' + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE ' . $whereSql . ' + ORDER BY r.received_at_ms DESC + LIMIT 1500'; + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge([$sinceMs, $userId], $params)); + $scored = []; + while ($row = $stmt->fetch()) { + $payload = json_decode($row['payload_json'] ?? '', true); + if (!is_array($payload)) { + $payload = []; + } + report_enrich_list_row($row, $sinceMs); + $score = report_search_score($tokens, $row, $payload); + if ($score <= 0) { + continue; + } + $row['search_score'] = $score; + $scored[] = $row; + } + usort($scored, static function (array $a, array $b): int { + $sa = (int) ($a['search_score'] ?? 0); + $sb = (int) ($b['search_score'] ?? 0); + if ($sa !== $sb) { + return $sb <=> $sa; + } + return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0)); + }); + $total = count($scored); + $offset = ($page - 1) * $perPage; + $slice = array_slice($scored, $offset, $perPage); + self::finishListRows($slice, $sinceMs, false, false, true); + return [ + 'items' => $slice, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => 'relevance', + 'dir' => 'desc', + 'grouped' => false, + 'search' => $q, + ]; + } + + /** @param list> $items */ + private static function finishListRows( + array &$items, + int $sinceMs, + bool $grouped, + bool $similar = false, + bool $search = false + ): void { + foreach ($items as &$row) { + $row['viewed'] = (bool) ($row['viewed'] ?? false); + $row['tags'] = parse_report_tags($row['tags_json'] ?? '[]'); + report_enrich_list_row($row, $sinceMs); + $brief = report_brief_lines($row, $grouped); + if ($similar && !empty($row['similarity_tier'])) { + $tier = (string) $row['similarity_tier']; + $labels = [ + 'anchor' => 'This report', + 'exact' => 'Same fingerprint', + 'strong' => 'Strong match', + 'likely' => 'Likely similar', + 'guess' => 'Possible match', + ]; + array_unshift($brief, ($labels[$tier] ?? $tier) . ' · score ' . (int) ($row['similarity_score'] ?? 0)); + } + if ($search && !empty($row['search_score'])) { + array_unshift($brief, 'Search match · relevance ' . (int) $row['search_score']); + } + $row['brief'] = $brief; + unset($row['payload_json'], $row['tags_json']); + } + unset($row); + } + + private static function buildListOrder(string $sort, string $dir): string { + $dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC'; + $fpCnt = 'COALESCE(fp.fingerprint_cnt, 1)'; + if ($sort === 'priority') { + return "$fpCnt DESC, r.received_at_ms DESC, r.app_version DESC, r.device_model ASC, r.crash_type ASC"; + } + if ($sort === 'fingerprint_cnt') { + return "$fpCnt $dir, r.received_at_ms DESC"; + } + return "r.$sort $dir"; + } + + private static function listSelectColumns(): string { + return 'r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms, + r.device_model, r.app_version, r.payload_json, r.tags_json, + CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed, + COALESCE(fp.fingerprint_cnt, 1) AS fingerprint_cnt, + COALESCE(fp.fingerprint_recent_cnt, 0) AS fingerprint_recent_cnt'; + } + + /** One grouped scan for fingerprint counts (replaces per-row correlated subqueries). */ + private static function fingerprintStatsJoin(): string { + return 'LEFT JOIN ( + SELECT company_id, fingerprint, + COUNT(*) AS fingerprint_cnt, + SUM(CASE WHEN received_at_ms > ? THEN 1 ELSE 0 END) AS fingerprint_recent_cnt + FROM reports + GROUP BY company_id, fingerprint + ) fp ON fp.company_id = r.company_id AND fp.fingerprint = r.fingerprint'; + } + + public static function setCustomTags(int $reportId, array $tags): bool { + if (self::getById($reportId) === null) { + return false; + } + $pdo = Database::pdo(); + $normalized = normalize_report_tags($tags); + $json = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $stmt = $pdo->prepare('UPDATE reports SET tags_json = ? WHERE id = ?'); + $stmt->execute([$json ?: '[]', $reportId]); + return true; + } + + /** Distinct custom tags used across reports (for suggestions). */ + public static function listTagPresets(int $limit = 40): array { + $pdo = Database::pdo(); + $where = ['tags_json IS NOT NULL', "tags_json != '[]'"]; + $params = []; + self::appendCompanyScope('company_id', $where, $params); + $whereSql = implode(' AND ', $where); + $stmt = $pdo->prepare("SELECT tags_json FROM reports WHERE $whereSql LIMIT 500"); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + $map = []; + foreach ($rows as $row) { + foreach (parse_report_tags($row['tags_json'] ?? '[]') as $tag) { + $map[$tag['id']] = $tag; + } + } + return array_values($map); + } + + public static function markViewed(int $reportId, int $userId): void { + Database::upsertReportView( + $userId, + $reportId, + (int) round(microtime(true) * 1000) + ); + } + + public static function getById(int $id): ?array { + $pdo = Database::pdo(); + $userId = (int) (Auth::user()['id'] ?? 0); + $stmt = $pdo->prepare( + 'SELECT r.*, CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed + FROM reports r + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE r.id = ?' + ); + $stmt->execute([$userId, $id]); + $row = $stmt->fetch(); + if (!$row) { + return null; + } + if (!Rbac::canAccessReport($row)) { + return null; + } + $decoded = json_decode($row['payload_json'], true); + $row['payload'] = is_array($decoded) ? $decoded : []; + $row['tags'] = parse_report_tags($row['tags_json'] ?? '[]'); + $row['viewed'] = (bool) ($row['viewed'] ?? false); + return $row; + } + + /** + * @param list $where + * @param list $params + */ + private static function appendCompanyScope(string $column, array &$where, array &$params): void { + Rbac::appendCompanyScope($column, $where, $params); + } + + /** + * @param list $where + * @param list $params + * @param array $filters + */ + private static function appendTagFilters(array &$where, array &$params, array $filters): void { + $tags = $filters['tags'] ?? []; + if (!is_array($tags) || $tags === []) { + return; + } + $ids = TagCatalog::normalizeFilterIds($tags); + if ($ids === []) { + return; + } + $mode = strtolower((string) ($filters['tag_mode'] ?? 'and')) === 'or' ? 'or' : 'and'; + $clauses = []; + foreach ($ids as $id) { + $clauses[] = '(r.tags_json LIKE ? OR r.tags_json LIKE ?)'; + $params[] = '%"id":"' . $id . '"%'; + $params[] = '%"id": "' . $id . '"%'; + } + if ($mode === 'or') { + $where[] = '(' . implode(' OR ', $clauses) . ')'; + } else { + foreach ($clauses as $clause) { + $where[] = $clause; + } + } + } + +} diff --git a/src/TagCatalog.php b/src/TagCatalog.php new file mode 100644 index 0000000..7ef1088 --- /dev/null +++ b/src/TagCatalog.php @@ -0,0 +1,84 @@ +|null */ + private static ?array $workflow = null; + + /** @return list */ + public static function workflowPresets(): array { + if (self::$workflow !== null) { + return self::$workflow; + } + self::$workflow = [ + ['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3d8bfd'], + ['id' => 'fixed', 'label' => 'fixed', 'bg' => '#047857'], + ['id' => 'rejected', 'label' => 'rejected', 'bg' => '#dc2626'], + ['id' => 'done', 'label' => 'done', 'bg' => '#5c6b82'], + ['id' => 'not-reproducible', 'label' => 'not reproducible', 'bg' => '#d97706'], + ['id' => 'not-a-bug', 'label' => 'not a bug', 'bg' => '#6b7280'], + ['id' => 'duplicate', 'label' => 'duplicate', 'bg' => '#6d28d9'], + ]; + return self::$workflow; + } + + /** @return array */ + public static function workflowIds(): array { + $map = []; + foreach (self::workflowPresets() as $tag) { + $map[$tag['id']] = true; + } + return $map; + } + + public static function isWorkflowId(string $id): bool { + return isset(self::workflowIds()[$id]); + } + + /** + * @param list $raw from query (?tag=fixed&tag=duplicate) + * @return list + */ + public static function normalizeFilterIds(array $raw): array { + $out = []; + $seen = []; + foreach ($raw as $item) { + $id = strtolower(trim((string) $item)); + $id = preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? ''; + $id = trim($id, '-'); + if ($id === '' || strlen($id) > 32 || isset($seen[$id])) { + continue; + } + if (isset(array_flip(report_reserved_tag_ids())[$id])) { + continue; + } + $seen[$id] = true; + $out[] = $id; + if (count($out) >= 8) { + break; + } + } + return $out; + } + + /** + * Workflow presets first, then distinct custom tags from DB (excluding workflow ids). + * + * @return list + */ + public static function editorPresets(int $historicalLimit = 32): array { + $map = []; + foreach (self::workflowPresets() as $tag) { + $map[$tag['id']] = $tag; + } + foreach (ReportRepository::listTagPresets($historicalLimit + 20) as $tag) { + $id = (string) ($tag['id'] ?? ''); + if ($id === '' || isset($map[$id])) { + continue; + } + $map[$id] = $tag; + } + return array_values($map); + } +}