mirror of
git://f0xx.org/android_cast
synced 2026-07-29 07:20:00 +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:
@@ -65,8 +65,18 @@ Manual SQL (MariaDB, **as root** — `androidcast` has no `ALTER`):
|
|||||||
```sh
|
```sh
|
||||||
mysql -u root -p androidcast_crashes < sql/migrations/001_rbac_companies.sql
|
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/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.
|
Re-login after deploy so session includes company memberships.
|
||||||
|
|
||||||
## UI languages
|
## 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
|
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
|
## Default accounts
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ try {
|
|||||||
$pdo = Database::pdo();
|
$pdo = Database::pdo();
|
||||||
$tables = Database::listTables($pdo);
|
$tables = Database::listTables($pdo);
|
||||||
$out['tables'] = $tables;
|
$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)) {
|
if (in_array('reports', $tables, true)) {
|
||||||
$cols = Database::columnNames($pdo, 'reports');
|
$cols = Database::columnNames($pdo, 'reports');
|
||||||
$out['reports_columns'] = array_map(
|
$out['reports_columns'] = array_map(
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ if (!Auth::user()) {
|
|||||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
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');
|
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||||
$id = (int) ($_GET['id'] ?? 0);
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ if (!Auth::user()) {
|
|||||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
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') {
|
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'PUT' && strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||||
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ if (($data['ingest_kind'] ?? 'ticket') !== 'ticket') {
|
|||||||
json_out(['ok' => false, 'error' => 'ingest_kind must be ticket'], 400);
|
json_out(['ok' => false, 'error' => 'ingest_kind must be ticket'], 400);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
Database::requireTicketsTable();
|
||||||
TicketRepository::insert($data);
|
TicketRepository::insert($data);
|
||||||
json_out(['ok' => true, 'ticket_id' => $data['ticket_id'] ?? null]);
|
json_out(['ok' => true, 'ticket_id' => $data['ticket_id'] ?? null]);
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
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) {
|
} catch (PDOException $e) {
|
||||||
$msg = $e->getMessage();
|
$msg = $e->getMessage();
|
||||||
if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) {
|
if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) {
|
||||||
|
|||||||
@@ -16,8 +16,16 @@ $dir = (string) ($_GET['dir'] ?? 'desc');
|
|||||||
$tag = trim((string) ($_GET['tag'] ?? ''));
|
$tag = trim((string) ($_GET['tag'] ?? ''));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Database::requireTicketsTable();
|
||||||
$data = TicketRepository::listPage($page, $perPage, $sort, $dir, $tag);
|
$data = TicketRepository::listPage($page, $perPage, $sort, $dir, $tag);
|
||||||
json_out(['ok' => true] + $data);
|
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) {
|
} catch (Throwable $e) {
|
||||||
error_log('tickets api: ' . $e->getMessage());
|
error_log('tickets api: ' . $e->getMessage());
|
||||||
$out = ['ok' => false, 'error' => 'list failed'];
|
$out = ['ok' => false, 'error' => 'list failed'];
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ if ($view === 'report' && isset($_GET['id'])) {
|
|||||||
|
|
||||||
if ($view === 'ticket' && isset($_GET['id'])) {
|
if ($view === 'ticket' && isset($_GET['id'])) {
|
||||||
try {
|
try {
|
||||||
|
Database::requireTicketsTable();
|
||||||
$ticket = TicketRepository::getById((int) $_GET['id']);
|
$ticket = TicketRepository::getById((int) $_GET['id']);
|
||||||
if (!$ticket) {
|
if (!$ticket) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ set -eu
|
|||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
SCHEMA="$ROOT/sql/schema.mariadb.sql"
|
SCHEMA="$ROOT/sql/schema.mariadb.sql"
|
||||||
MIG002="$ROOT/sql/migrations/002_reports_company_columns.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_NAME="${CRASH_DB_NAME:-androidcast_crashes}"
|
||||||
DB_USER="${CRASH_DB_USER:-androidcast}"
|
DB_USER="${CRASH_DB_USER:-androidcast}"
|
||||||
DB_PASS="${CRASH_DB_PASS:-}"
|
DB_PASS="${CRASH_DB_PASS:-}"
|
||||||
@@ -25,6 +26,11 @@ if [ -f "$MIG002" ]; then
|
|||||||
mysql -u root -p "$DB_NAME" < "$MIG002"
|
mysql -u root -p "$DB_NAME" < "$MIG002"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -f "$MIG003" ]; then
|
||||||
|
echo "Applying tickets table (003) ..."
|
||||||
|
mysql -u root -p "$DB_NAME" < "$MIG003"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "$DB_PASS" ]; then
|
if [ -n "$DB_PASS" ]; then
|
||||||
echo "Creating app user ${DB_USER}@localhost and @127.0.0.1 ..."
|
echo "Creating app user ${DB_USER}@localhost and @127.0.0.1 ..."
|
||||||
mysql -u root -p <<EOF
|
mysql -u root -p <<EOF
|
||||||
|
|||||||
27
examples/crash_reporter/backend/scripts/migrate-003-tickets.sh
Executable file
27
examples/crash_reporter/backend/scripts/migrate-003-tickets.sh
Executable 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."
|
||||||
@@ -87,6 +87,36 @@ CREATE TABLE IF NOT EXISTS report_views (
|
|||||||
FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE
|
FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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 companies (id, slug, name) VALUES (1, 'default', 'Default');
|
||||||
|
|
||||||
INSERT IGNORE INTO users (id, username, password_hash, role)
|
INSERT IGNORE INTO users (id, username, password_hash, role)
|
||||||
|
|||||||
@@ -67,6 +67,34 @@ CREATE TABLE IF NOT EXISTS report_views (
|
|||||||
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE
|
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_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_generated ON reports(generated_at_ms DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_at_ms DESC);
|
CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_at_ms DESC);
|
||||||
|
|||||||
@@ -159,23 +159,50 @@ final class Database {
|
|||||||
self::ensureTicketsTable($pdo);
|
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 {
|
public static function ensureTicketsTable(?PDO $pdo = null): void {
|
||||||
$pdo ??= self::$pdo;
|
$pdo ??= self::$pdo;
|
||||||
if ($pdo === null || self::tableExists($pdo, 'tickets')) {
|
if ($pdo === null || self::tableExists($pdo, 'tickets')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$ok = false;
|
||||||
if (self::isMysql()) {
|
if (self::isMysql()) {
|
||||||
$sql = file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sql') ?: '';
|
$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;
|
$sql = preg_replace('/^\s*USE\s+[^;]+;\s*/mi', '', $sql) ?? $sql;
|
||||||
self::execSchemaSql($pdo, $sql, 'create tickets mysql');
|
$ok = self::execSchemaSql($pdo, trim($sql), 'create tickets mysql');
|
||||||
return;
|
} else {
|
||||||
}
|
$ok = self::execSchemaSql(
|
||||||
self::execSchemaSql(
|
|
||||||
$pdo,
|
$pdo,
|
||||||
file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sqlite.sql') ?: '',
|
file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sqlite.sql') ?: '',
|
||||||
'create tickets sqlite'
|
'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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run DDL; app DB user often has no ALTER — log and continue (run sql/migrations as root).
|
* Run DDL; app DB user often has no ALTER — log and continue (run sql/migrations as root).
|
||||||
|
|||||||
Reference in New Issue
Block a user