1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 03:38:52 +03:00

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 <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-05-24 21:35:57 +02:00
parent 4e5f1a3732
commit b5e83e4b4f
12 changed files with 165 additions and 8 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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'];

View File

@@ -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);

View File

@@ -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 <<EOF

View File

@@ -0,0 +1,27 @@
#!/bin/sh
# Apply tickets table migration (003) on existing MariaDB installs.
# Requires MySQL root (app user androidcast cannot CREATE TABLE).
#
# Usage (from backend/):
# ./scripts/migrate-003-tickets.sh
# CRASH_DB_NAME=my_db ./scripts/migrate-003-tickets.sh
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SQL="$ROOT/sql/migrations/003_tickets.sql"
DB_NAME="${CRASH_DB_NAME:-androidcast_crashes}"
if [ ! -f "$SQL" ]; then
echo "Missing $SQL" >&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."

View File

@@ -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)

View File

@@ -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);

View File

@@ -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'
);
}
/**