From b5e83e4b4f4cf1627865b647f59c23e2c3d017b8 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 24 May 2026 21:35:57 +0200 Subject: [PATCH] Document and apply MariaDB migration 003 for tickets table. App user cannot CREATE TABLE on production MariaDB; add migrate-003-tickets.sh, wire init-mariadb and base schema, and return 503 with migration hint when tickets is missing instead of opaque SQL errors. Co-authored-by: Cursor --- examples/crash_reporter/backend/README.md | 12 +++++- .../backend/public/api/diag.php | 4 ++ .../backend/public/api/ticket_tags.php | 6 +++ .../backend/public/api/ticket_update.php | 6 +++ .../backend/public/api/ticket_upload.php | 4 ++ .../backend/public/api/tickets.php | 8 ++++ .../crash_reporter/backend/public/index.php | 1 + .../backend/scripts/init-mariadb.sh | 6 +++ .../backend/scripts/migrate-003-tickets.sh | 27 ++++++++++++ .../backend/sql/schema.mariadb.sql | 30 ++++++++++++++ .../backend/sql/schema.sqlite.sql | 28 +++++++++++++ .../crash_reporter/backend/src/Database.php | 41 +++++++++++++++---- 12 files changed, 165 insertions(+), 8 deletions(-) create mode 100755 examples/crash_reporter/backend/scripts/migrate-003-tickets.sh diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md index ea08762..948fd3a 100644 --- a/examples/crash_reporter/backend/README.md +++ b/examples/crash_reporter/backend/README.md @@ -65,8 +65,18 @@ Manual SQL (MariaDB, **as root** — `androidcast` has no `ALTER`): ```sh mysql -u root -p androidcast_crashes < sql/migrations/001_rbac_companies.sql mysql -u root -p androidcast_crashes < sql/migrations/002_reports_company_columns.sql +mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql ``` +Or from `backend/`: + +```sh +chmod +x scripts/migrate-003-tickets.sh +./scripts/migrate-003-tickets.sh +``` + +The PHP app user (`androidcast`) **cannot** create `tickets` on MariaDB — auto-migrate only works for SQLite dev. If Tickets shows *table missing*, run **003** as root once. + Re-login after deploy so session includes company memberships. ## UI languages @@ -175,7 +185,7 @@ Tags must include **`ticket`** plus at least one status tag (`open`, `in-progres php scripts/ingest_alpha_smoke_tickets.php --url=https://apps.f0xx.org/app/androidcast_project/crashes ``` -MariaDB: `mysql … < sql/migrations/003_tickets.sql` (or auto-create on first request via SQLite dev). +MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql` or `./scripts/migrate-003-tickets.sh`. Fresh installs: included in `sql/schema.mariadb.sql` and `scripts/init-mariadb.sh`. ## Default accounts diff --git a/examples/crash_reporter/backend/public/api/diag.php b/examples/crash_reporter/backend/public/api/diag.php index fe8733b..336f01b 100644 --- a/examples/crash_reporter/backend/public/api/diag.php +++ b/examples/crash_reporter/backend/public/api/diag.php @@ -41,6 +41,10 @@ try { $pdo = Database::pdo(); $tables = Database::listTables($pdo); $out['tables'] = $tables; + $out['tickets_table'] = in_array('tickets', $tables, true); + 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( diff --git a/examples/crash_reporter/backend/public/api/ticket_tags.php b/examples/crash_reporter/backend/public/api/ticket_tags.php index 25709f4..21e9a06 100644 --- a/examples/crash_reporter/backend/public/api/ticket_tags.php +++ b/examples/crash_reporter/backend/public/api/ticket_tags.php @@ -6,6 +6,12 @@ if (!Auth::user()) { json_out(['ok' => false, 'error' => 'unauthorized'], 401); } +try { + Database::requireTicketsTable(); +} catch (RuntimeException $e) { + json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503); +} + $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); $id = (int) ($_GET['id'] ?? 0); diff --git a/examples/crash_reporter/backend/public/api/ticket_update.php b/examples/crash_reporter/backend/public/api/ticket_update.php index 314e7e7..c2343e7 100644 --- a/examples/crash_reporter/backend/public/api/ticket_update.php +++ b/examples/crash_reporter/backend/public/api/ticket_update.php @@ -6,6 +6,12 @@ if (!Auth::user()) { json_out(['ok' => false, 'error' => 'unauthorized'], 401); } +try { + Database::requireTicketsTable(); +} catch (RuntimeException $e) { + json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503); +} + if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'PUT' && strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { json_out(['ok' => false, 'error' => 'method not allowed'], 405); } diff --git a/examples/crash_reporter/backend/public/api/ticket_upload.php b/examples/crash_reporter/backend/public/api/ticket_upload.php index 67f1a40..b30c0b0 100644 --- a/examples/crash_reporter/backend/public/api/ticket_upload.php +++ b/examples/crash_reporter/backend/public/api/ticket_upload.php @@ -17,10 +17,14 @@ if (($data['ingest_kind'] ?? 'ticket') !== 'ticket') { json_out(['ok' => false, 'error' => 'ingest_kind must be ticket'], 400); } try { + Database::requireTicketsTable(); TicketRepository::insert($data); json_out(['ok' => true, 'ticket_id' => $data['ticket_id'] ?? null]); } catch (InvalidArgumentException $e) { json_out(['ok' => false, 'error' => $e->getMessage()], 400); +} catch (RuntimeException $e) { + error_log('ticket upload: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503); } catch (PDOException $e) { $msg = $e->getMessage(); if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) { diff --git a/examples/crash_reporter/backend/public/api/tickets.php b/examples/crash_reporter/backend/public/api/tickets.php index ff6207f..508ab64 100644 --- a/examples/crash_reporter/backend/public/api/tickets.php +++ b/examples/crash_reporter/backend/public/api/tickets.php @@ -16,8 +16,16 @@ $dir = (string) ($_GET['dir'] ?? 'desc'); $tag = trim((string) ($_GET['tag'] ?? '')); try { + Database::requireTicketsTable(); $data = TicketRepository::listPage($page, $perPage, $sort, $dir, $tag); json_out(['ok' => true] + $data); +} catch (RuntimeException $e) { + error_log('tickets api: ' . $e->getMessage()); + json_out([ + 'ok' => false, + 'error' => 'tickets_table_missing', + 'hint' => $e->getMessage(), + ], 503); } catch (Throwable $e) { error_log('tickets api: ' . $e->getMessage()); $out = ['ok' => false, 'error' => 'list failed']; diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php index 3278f50..2a8de9e 100644 --- a/examples/crash_reporter/backend/public/index.php +++ b/examples/crash_reporter/backend/public/index.php @@ -126,6 +126,7 @@ if ($view === 'report' && isset($_GET['id'])) { if ($view === 'ticket' && isset($_GET['id'])) { try { + Database::requireTicketsTable(); $ticket = TicketRepository::getById((int) $_GET['id']); if (!$ticket) { http_response_code(404); diff --git a/examples/crash_reporter/backend/scripts/init-mariadb.sh b/examples/crash_reporter/backend/scripts/init-mariadb.sh index 09bf34e..35b1401 100755 --- a/examples/crash_reporter/backend/scripts/init-mariadb.sh +++ b/examples/crash_reporter/backend/scripts/init-mariadb.sh @@ -8,6 +8,7 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd)" SCHEMA="$ROOT/sql/schema.mariadb.sql" MIG002="$ROOT/sql/migrations/002_reports_company_columns.sql" +MIG003="$ROOT/sql/migrations/003_tickets.sql" DB_NAME="${CRASH_DB_NAME:-androidcast_crashes}" DB_USER="${CRASH_DB_USER:-androidcast}" DB_PASS="${CRASH_DB_PASS:-}" @@ -25,6 +26,11 @@ if [ -f "$MIG002" ]; then mysql -u root -p "$DB_NAME" < "$MIG002" fi +if [ -f "$MIG003" ]; then + echo "Applying tickets table (003) ..." + mysql -u root -p "$DB_NAME" < "$MIG003" +fi + if [ -n "$DB_PASS" ]; then echo "Creating app user ${DB_USER}@localhost and @127.0.0.1 ..." mysql -u root -p <&2 + exit 1 +fi + +echo "Applying tickets migration to database: $DB_NAME" +echo "File: $SQL" +echo "(you will be prompted for MySQL root password)" +mysql -u root -p "$DB_NAME" < "$SQL" + +echo "Verifying tickets table..." +mysql -u root -p "$DB_NAME" -e "SHOW TABLES LIKE 'tickets'; DESCRIBE tickets;" | head -20 + +echo "Done. Restart php-fpm if needed, then reload Tickets in the console." diff --git a/examples/crash_reporter/backend/sql/schema.mariadb.sql b/examples/crash_reporter/backend/sql/schema.mariadb.sql index bf2fb20..0722610 100644 --- a/examples/crash_reporter/backend/sql/schema.mariadb.sql +++ b/examples/crash_reporter/backend/sql/schema.mariadb.sql @@ -87,6 +87,36 @@ CREATE TABLE IF NOT EXISTS report_views ( FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS tickets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + device_id BIGINT UNSIGNED NULL, + ticket_id VARCHAR(128) NOT NULL, + title VARCHAR(256) NOT NULL, + brief TEXT NULL, + body LONGTEXT NOT NULL, + opened_at_ms BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + owner_username VARCHAR(64) NOT NULL DEFAULT '', + verified_by_username VARCHAR(64) NULL, + device_model VARCHAR(128) NULL, + app_package VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + os_name VARCHAR(64) NULL, + os_version VARCHAR(128) NULL, + rating TINYINT UNSIGNED NOT NULL DEFAULT 0, + tags_json LONGTEXT NOT NULL, + payload_json LONGTEXT NOT NULL, + UNIQUE KEY uq_tickets_ticket_id (ticket_id), + KEY idx_tickets_company (company_id), + KEY idx_tickets_opened (opened_at_ms), + KEY idx_tickets_updated (updated_at_ms), + KEY idx_tickets_owner (owner_username), + CONSTRAINT fk_tickets_company FOREIGN KEY (company_id) REFERENCES companies (id), + CONSTRAINT fk_tickets_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default'); INSERT IGNORE INTO users (id, username, password_hash, role) diff --git a/examples/crash_reporter/backend/sql/schema.sqlite.sql b/examples/crash_reporter/backend/sql/schema.sqlite.sql index 8b5dd3d..6bc850c 100644 --- a/examples/crash_reporter/backend/sql/schema.sqlite.sql +++ b/examples/crash_reporter/backend/sql/schema.sqlite.sql @@ -67,6 +67,34 @@ CREATE TABLE IF NOT EXISTS report_views ( FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL DEFAULT 1, + device_id INTEGER NULL, + ticket_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + brief TEXT, + body TEXT NOT NULL, + opened_at_ms INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + owner_username TEXT NOT NULL DEFAULT '', + verified_by_username TEXT, + device_model TEXT, + app_package TEXT, + app_version TEXT, + os_name TEXT, + os_version TEXT, + rating INTEGER NOT NULL DEFAULT 0, + tags_json TEXT NOT NULL DEFAULT '[]', + payload_json TEXT NOT NULL, + FOREIGN KEY (company_id) REFERENCES companies(id), + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_tickets_company ON tickets(company_id); +CREATE INDEX IF NOT EXISTS idx_tickets_opened ON tickets(opened_at_ms DESC); + CREATE INDEX IF NOT EXISTS idx_reports_company ON reports(company_id); CREATE INDEX IF NOT EXISTS idx_reports_generated ON reports(generated_at_ms DESC); CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_at_ms DESC); diff --git a/examples/crash_reporter/backend/src/Database.php b/examples/crash_reporter/backend/src/Database.php index 8f6af1e..b187d13 100644 --- a/examples/crash_reporter/backend/src/Database.php +++ b/examples/crash_reporter/backend/src/Database.php @@ -159,22 +159,49 @@ final class Database { self::ensureTicketsTable($pdo); } + public static function ticketsTableExists(?PDO $pdo = null): bool { + $pdo ??= self::$pdo; + return $pdo !== null && self::tableExists($pdo, 'tickets'); + } + + /** @throws RuntimeException when `tickets` is missing (MariaDB needs root migration). */ + public static function requireTicketsTable(?PDO $pdo = null): void { + self::ensureTicketsTable($pdo); + if (!self::ticketsTableExists($pdo)) { + throw new RuntimeException(self::ticketsMigrationHint()); + } + } + + public static function ticketsMigrationHint(): string { + return 'Table tickets is missing. On MariaDB run as MySQL root: ' + . 'mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql ' + . '(or ./scripts/migrate-003-tickets.sh from backend/)'; + } + public static function ensureTicketsTable(?PDO $pdo = null): void { $pdo ??= self::$pdo; if ($pdo === null || self::tableExists($pdo, 'tickets')) { return; } + $ok = false; if (self::isMysql()) { $sql = file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sql') ?: ''; + $sql = preg_replace('/^\s*--.*$/m', '', $sql) ?? $sql; $sql = preg_replace('/^\s*USE\s+[^;]+;\s*/mi', '', $sql) ?? $sql; - self::execSchemaSql($pdo, $sql, 'create tickets mysql'); - return; + $ok = self::execSchemaSql($pdo, trim($sql), 'create tickets mysql'); + } else { + $ok = self::execSchemaSql( + $pdo, + file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sqlite.sql') ?: '', + 'create tickets sqlite' + ); + } + if (!$ok && !self::tableExists($pdo, 'tickets')) { + error_log('crash_reporter: ' . self::ticketsMigrationHint()); + if (function_exists('log_crash_event')) { + log_crash_event('schema', self::ticketsMigrationHint()); + } } - self::execSchemaSql( - $pdo, - file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sqlite.sql') ?: '', - 'create tickets sqlite' - ); } /**