1
0
mirror of git://f0xx.org/ac/ac-ms-tickets synced 2026-07-29 00:59:22 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:32 +02:00
commit 040e2fa9ca
23 changed files with 1545 additions and 0 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# ac-ms-tickets
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-tickets`

25
composer.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "androidcast/ms-tickets",
"description": "Ticket workflow",
"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/"
]
}
}

View File

@@ -0,0 +1,2 @@
<?php
return ['db' => ['driver' => 'sqlite']];

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
http_response_code(401);
echo 'Unauthorized';
exit;
}
try {
Database::requireTicketsTable();
} catch (RuntimeException $e) {
http_response_code(503);
echo 'Tickets unavailable';
exit;
}
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
exit;
}
$att = TicketAttachmentRepository::getById($id);
if (!$att || ($att['kind'] ?? '') !== 'file') {
http_response_code(404);
exit;
}
$path = TicketAttachmentRepository::filePath($att);
if ($path === null) {
http_response_code(404);
exit;
}
$name = (string) ($att['original_name'] ?? 'download');
$mime = (string) ($att['mime_type'] ?? 'application/octet-stream');
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string) filesize($path));
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $name) . '"');
header('X-Content-Type-Options: nosniff');
readfile($path);

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
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);
}
$user = Auth::user();
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
if ($method === 'GET') {
$ticketId = (int) ($_GET['ticket_id'] ?? 0);
if ($ticketId <= 0) {
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
}
try {
json_out(['ok' => true, 'attachments' => TicketAttachmentRepository::listForTicket($ticketId)]);
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'ticket_attachments') || str_contains($e->getMessage(), 'no such table')) {
json_out(['ok' => false, 'error' => 'attachments_table_missing', 'hint' => 'Run sql/migrations/005_ticket_attachments.sql'], 503);
}
throw $e;
}
}
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'invalid id'], 400);
}
try {
TicketAttachmentRepository::delete($id, (string) ($user['username'] ?? ''));
json_out(['ok' => true]);
} catch (RuntimeException $e) {
$code = $e->getMessage() === 'forbidden' ? 403 : 404;
json_out(['ok' => false, 'error' => $e->getMessage()], $code);
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'ticket_attachments')) {
json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503);
}
throw $e;
}
}
if ($method !== 'POST') {
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
}
$ticketId = (int) ($_POST['ticket_id'] ?? 0);
if ($ticketId <= 0) {
$raw = file_get_contents('php://input');
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null;
if (is_array($data)) {
$ticketId = (int) ($data['ticket_id'] ?? 0);
}
}
if ($ticketId <= 0) {
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
}
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
// Any logged-in user who can view the ticket may add attachments (same as comments).
try {
if (!empty($_FILES['file']['name'])) {
$att = TicketAttachmentRepository::addFile(
$ticketId,
(string) ($user['username'] ?? ''),
$_FILES['file'],
trim((string) ($_POST['title'] ?? ''))
);
json_out(['ok' => true, 'attachment' => $att]);
}
$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' => 'file or url required'], 400);
}
$url = trim((string) ($data['url'] ?? $data['external_url'] ?? ''));
if ($url === '') {
json_out(['ok' => false, 'error' => 'url required'], 400);
}
$att = TicketAttachmentRepository::addLink(
$ticketId,
(string) ($user['username'] ?? ''),
$url,
trim((string) ($data['title'] ?? ''))
);
json_out(['ok' => true, 'attachment' => $att]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'ticket_attachments')) {
json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503);
}
error_log('ticket_attachments: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
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');
if ($method === 'GET') {
$ticketId = (int) ($_GET['ticket_id'] ?? 0);
if ($ticketId <= 0) {
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
}
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
try {
json_out(['ok' => true, 'comments' => TicketCommentRepository::listForTicket($ticketId)]);
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) {
json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503);
}
throw $e;
}
}
if ($method !== 'POST') {
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
}
$user = Auth::user();
$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);
}
$ticketId = (int) ($data['ticket_id'] ?? 0);
if ($ticketId <= 0) {
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
}
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
if (empty($ticket['can_edit']) && !Auth::canEditTags()) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
try {
$comment = TicketCommentRepository::add(
$ticketId,
(string) ($user['username'] ?? ''),
(string) ($data['body'] ?? '')
);
json_out(['ok' => true, 'comment' => $comment]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) {
json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503);
}
error_log('ticket_comments: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
$raw = file_get_contents('php://input');
$data = json_decode($raw !== false ? $raw : '', true);
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid json'], 400);
}
$user = Auth::user();
$title = trim((string) ($data['title'] ?? ''));
$body = trim((string) ($data['body'] ?? ''));
$brief = trim((string) ($data['brief'] ?? ''));
if ($title === '') {
json_out(['ok' => false, 'error' => 'title required'], 400);
}
if ($body === '' && $brief !== '') {
$body = $brief;
}
$tags = is_array($data['tags'] ?? null) ? $data['tags'] : [];
if ($tags === []) {
$tags = [['id' => 'open', 'label' => 'open', 'bg' => '#22c55e']];
}
$now = (int) round(microtime(true) * 1000);
$payload = [
'schema_version' => 1,
'ingest_kind' => 'ticket',
'ticket_id' => 'web-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)),
'title' => mb_substr($title, 0, 256),
'brief' => $brief !== '' ? mb_substr($brief, 0, 512) : mb_substr($title, 0, 512),
'body' => $body !== '' ? $body : $title,
'opened_at_epoch_ms' => $now,
'rating' => max(0, min(5, (int) ($data['rating'] ?? 3))),
'owner' => (string) ($user['username'] ?? 'admin'),
'tags' => $tags,
'device' => [
'manufacturer' => 'web',
'model' => 'console',
'sdk_int' => 0,
'release' => 'n/a',
],
'app' => [
'package' => 'com.foxx.androidcast',
'version_name' => 'console',
'version_code' => 0,
],
];
try {
Database::requireTicketsTable();
$id = TicketRepository::insert($payload);
json_out(['ok' => true, 'id' => $id, 'ticket_id' => $payload['ticket_id']]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
error_log('ticket_create: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'create failed'], 500);
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
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);
if ($method === 'GET') {
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'invalid id'], 400);
}
$ticket = TicketRepository::getById($id);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
json_out([
'ok' => true,
'id' => $id,
'tags' => $ticket['tags'] ?? [],
'presets' => TicketRepository::listTagPresets(),
'can_edit' => !empty($ticket['can_edit']),
]);
}
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);
}
$ticket = TicketRepository::getById($id);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
if (empty($ticket['can_edit'])) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$tagsIn = $data['tags'] ?? [];
if (!is_array($tagsIn)) {
json_out(['ok' => false, 'error' => 'tags must be array'], 400);
}
try {
TicketRepository::setCustomTags($id, $tagsIn);
$updated = TicketRepository::getById($id);
json_out([
'ok' => true,
'id' => $id,
'tags' => $updated['tags'] ?? [],
]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
error_log('ticket_tags: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
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);
}
$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'] ?? 0);
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'invalid id'], 400);
}
$ticket = TicketRepository::getById($id);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
if (empty($ticket['can_edit'])) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$fields = [];
foreach (['title', 'brief', 'body', 'rating', 'owner_username', 'verified_by_username', 'workflow_state'] as $key) {
if (array_key_exists($key, $data)) {
$fields[$key] = $data[$key];
}
}
if (array_key_exists('assignees', $data)) {
$fields['assignees'] = $data['assignees'];
}
try {
TicketRepository::update($id, $fields);
$updated = TicketRepository::getById($id);
json_out(['ok' => true, 'ticket' => $updated]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
error_log('ticket_update: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
json_out(['ok' => false, 'error' => 'empty 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);
}
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) {
json_out(['ok' => false, 'error' => 'duplicate ticket_id'], 409);
}
error_log('ticket upload PDO: ' . $msg);
$out = ['ok' => false, 'error' => 'store failed'];
if (cfg('debug')) {
$out['hint'] = $msg;
}
json_out($out, 500);
} catch (Throwable $e) {
error_log('ticket upload: ' . $e->getMessage());
$out = ['ok' => false, 'error' => 'store failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

49
public/api/tickets.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = (int) ($_GET['per_page'] ?? 50);
if (!in_array($perPage, [25, 50, 75, 100, 200], true)) {
$perPage = 50;
}
$sort = (string) ($_GET['sort'] ?? 'opened_at_ms');
$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());
$out = [
'ok' => false,
'error' => 'tickets_table_missing',
'hint' => $e->getMessage(),
];
if (cfg('debug')) {
try {
$pdo = Database::pdo();
$out['db_driver'] = Database::driver();
$out['tickets_table'] = Database::ticketsTableExists($pdo);
if (Database::isMysql()) {
$out['mysql_database'] = $pdo->query('SELECT DATABASE()')->fetchColumn();
}
} catch (Throwable $diag) {
$out['diag_error'] = $diag->getMessage();
}
}
json_out($out, 503);
} catch (Throwable $e) {
error_log('tickets api: ' . $e->getMessage());
$out = ['ok' => false, 'error' => 'list failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

9
public/api/users.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
json_out(['ok' => true, 'users' => UserRepository::listForAssignees()]);

2
public/index.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
// API entry — wire nginx to public/

View File

@@ -0,0 +1,32 @@
-- Tickets (tasks / QA / user issues) — same console as crash reports.
-- Run: mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql
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;

View File

@@ -0,0 +1,27 @@
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);

View File

@@ -0,0 +1,18 @@
-- Ticket workflow, assignees, comments. Run as MySQL root on existing DBs.
-- mysql -u root -p androidcast_crashes < sql/migrations/004_ticket_workflow.sql
ALTER TABLE tickets
ADD COLUMN workflow_state VARCHAR(32) NOT NULL DEFAULT 'triage' AFTER tags_json,
ADD COLUMN assignees_json LONGTEXT NOT NULL DEFAULT '[]' AFTER workflow_state;
CREATE TABLE IF NOT EXISTS ticket_comments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ticket_id BIGINT UNSIGNED NOT NULL,
author_username VARCHAR(64) NOT NULL,
body LONGTEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
KEY idx_ticket_comments_ticket (ticket_id),
KEY idx_ticket_comments_created (created_at_ms),
CONSTRAINT fk_ticket_comments_ticket FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,20 @@
-- Ticket attachments: uploaded files + external links (incl. Google URLs).
-- mysql -u root -p androidcast_crashes < sql/migrations/005_ticket_attachments.sql
CREATE TABLE IF NOT EXISTS ticket_attachments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ticket_id BIGINT UNSIGNED NOT NULL,
kind ENUM('file', 'link') NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '',
external_url TEXT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'generic',
storage_name VARCHAR(128) NULL,
original_name VARCHAR(256) NULL,
mime_type VARCHAR(128) NULL,
size_bytes BIGINT UNSIGNED NULL,
created_by VARCHAR(64) NOT NULL,
created_at_ms BIGINT NOT NULL,
KEY idx_ticket_attachments_ticket (ticket_id),
KEY idx_ticket_attachments_created (created_at_ms),
CONSTRAINT fk_ticket_attachments_ticket FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/** Classify external URLs (Google Suite labels now; OAuth/embed later). */
final class TicketAttachmentProvider {
public const GENERIC = 'generic';
public const GOOGLE_DOCS = 'google_docs';
public const GOOGLE_SHEETS = 'google_sheets';
public const GOOGLE_SLIDES = 'google_slides';
public const GOOGLE_DRIVE = 'google_drive';
public const GOOGLE_MEET = 'google_meet';
public const GOOGLE_CHAT = 'google_chat';
public const GOOGLE_FORMS = 'google_forms';
/** @return array{id:string,label:string}> */
public static function labels(): array {
return [
self::GENERIC => ['id' => self::GENERIC, 'label' => 'Link'],
self::GOOGLE_DOCS => ['id' => self::GOOGLE_DOCS, 'label' => 'Google Docs'],
self::GOOGLE_SHEETS => ['id' => self::GOOGLE_SHEETS, 'label' => 'Google Sheets'],
self::GOOGLE_SLIDES => ['id' => self::GOOGLE_SLIDES, 'label' => 'Google Slides'],
self::GOOGLE_DRIVE => ['id' => self::GOOGLE_DRIVE, 'label' => 'Google Drive'],
self::GOOGLE_MEET => ['id' => self::GOOGLE_MEET, 'label' => 'Google Meet'],
self::GOOGLE_CHAT => ['id' => self::GOOGLE_CHAT, 'label' => 'Google Chat'],
self::GOOGLE_FORMS => ['id' => self::GOOGLE_FORMS, 'label' => 'Google Forms'],
];
}
public static function labelFor(string $provider): string {
$labels = self::labels();
return $labels[$provider]['label'] ?? 'Link';
}
public static function detectFromUrl(string $url): string {
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
if ($host === '') {
return self::GENERIC;
}
if (str_contains($host, 'docs.google.com')) {
return self::GOOGLE_DOCS;
}
if (str_contains($host, 'sheets.google.com')) {
return self::GOOGLE_SHEETS;
}
if (str_contains($host, 'slides.google.com')) {
return self::GOOGLE_SLIDES;
}
if (str_contains($host, 'drive.google.com')) {
return self::GOOGLE_DRIVE;
}
if (str_contains($host, 'meet.google.com')) {
return self::GOOGLE_MEET;
}
if (str_contains($host, 'chat.google.com')) {
return self::GOOGLE_CHAT;
}
if (str_contains($host, 'forms.gle') || str_contains($host, 'forms.google.com')) {
return self::GOOGLE_FORMS;
}
return self::GENERIC;
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
final class TicketAttachmentRepository {
private const MAX_BYTES = 16777216; // 16 MiB
/** @return list<string> */
private static function allowedExtensions(): array {
return [
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg',
'pdf', 'txt', 'log', 'md', 'json', 'xml', 'zip',
'mp4', 'webm', 'mp3', 'wav',
];
}
public static function storageRoot(): string {
$dir = dirname(__DIR__) . '/storage/ticket_attachments';
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
public static function listForTicket(int $ticketId): array {
// Do not call TicketRepository::getById here — avoids recursion from mapDetailRow.
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'SELECT * FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at_ms ASC, id ASC'
);
$stmt->execute([$ticketId]);
$bp = Auth::basePath();
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[] = self::mapRow($row, $bp);
}
return $out;
}
public static function getById(int $id): ?array {
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE id = ? LIMIT 1');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
$ticket = TicketRepository::getById((int) $row['ticket_id']);
if (!$ticket) {
return null;
}
return self::mapRow($row, Auth::basePath());
}
public static function addLink(int $ticketId, string $author, string $url, string $title = ''): array {
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
throw new RuntimeException('not found');
}
$url = trim($url);
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
throw new InvalidArgumentException('valid url required');
}
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
if (!in_array($scheme, ['http', 'https'], true)) {
throw new InvalidArgumentException('http or https url required');
}
$provider = TicketAttachmentProvider::detectFromUrl($url);
if ($title === '') {
$title = TicketAttachmentProvider::labelFor($provider);
}
$now = (int) round(microtime(true) * 1000);
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'INSERT INTO ticket_attachments (ticket_id, kind, title, external_url, provider,
storage_name, original_name, mime_type, size_bytes, created_by, created_at_ms)
VALUES (?, \'link\', ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?)'
);
$stmt->execute([
$ticketId,
mb_substr($title, 0, 256),
$url,
$provider,
mb_substr(trim($author), 0, 64),
$now,
]);
self::touchTicket($ticketId, $now);
return self::getById((int) $pdo->lastInsertId()) ?? [];
}
/** @param array{name:string,type:string,tmp_name:string,error:int,size:int} $file */
public static function addFile(int $ticketId, string $author, array $file, string $title = ''): array {
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
throw new RuntimeException('not found');
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new InvalidArgumentException('upload failed');
}
$size = (int) ($file['size'] ?? 0);
if ($size <= 0 || $size > self::MAX_BYTES) {
throw new InvalidArgumentException('file too large (max 16 MiB)');
}
$original = basename((string) ($file['name'] ?? 'file'));
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
if ($ext === '' || !in_array($ext, self::allowedExtensions(), true)) {
throw new InvalidArgumentException('file type not allowed');
}
$mime = (string) ($file['type'] ?? 'application/octet-stream');
$storageName = bin2hex(random_bytes(16)) . '.' . $ext;
$dir = self::storageRoot() . '/' . $ticketId;
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$dest = $dir . '/' . $storageName;
if (!move_uploaded_file((string) $file['tmp_name'], $dest)) {
throw new RuntimeException('could not store file');
}
if ($title === '') {
$title = $original;
}
$now = (int) round(microtime(true) * 1000);
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'INSERT INTO ticket_attachments (ticket_id, kind, title, external_url, provider,
storage_name, original_name, mime_type, size_bytes, created_by, created_at_ms)
VALUES (?, \'file\', ?, NULL, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$ticketId,
mb_substr($title, 0, 256),
TicketAttachmentProvider::GENERIC,
$storageName,
mb_substr($original, 0, 256),
mb_substr($mime, 0, 128),
$size,
mb_substr(trim($author), 0, 64),
$now,
]);
self::touchTicket($ticketId, $now);
return self::getById((int) $pdo->lastInsertId()) ?? [];
}
public static function delete(int $id, string $username): void {
$att = self::getById($id);
if (!$att) {
throw new RuntimeException('not found');
}
$ticket = TicketRepository::getById((int) $att['ticket_id']);
if (!$ticket) {
throw new RuntimeException('not found');
}
$can = !empty($ticket['can_edit'])
|| strcasecmp((string) ($att['created_by'] ?? ''), $username) === 0
|| Auth::canEditTags();
if (!$can) {
throw new RuntimeException('forbidden');
}
if ($att['kind'] === 'file' && !empty($att['storage_name'])) {
$path = self::storageRoot() . '/' . $att['ticket_id'] . '/' . $att['storage_name'];
if (is_file($path)) {
@unlink($path);
}
}
$pdo = Database::pdo();
$pdo->prepare('DELETE FROM ticket_attachments WHERE id = ?')->execute([$id]);
self::touchTicket((int) $att['ticket_id'], (int) round(microtime(true) * 1000));
}
public static function filePath(array $attachment): ?string {
if (($attachment['kind'] ?? '') !== 'file' || empty($attachment['storage_name'])) {
return null;
}
$path = self::storageRoot() . '/' . (int) $attachment['ticket_id'] . '/' . $attachment['storage_name'];
return is_file($path) ? $path : null;
}
private static function touchTicket(int $ticketId, int $now): void {
Database::pdo()->prepare('UPDATE tickets SET updated_at_ms = ? WHERE id = ?')->execute([$now, $ticketId]);
}
/** @param array<string, mixed> $row */
private static function mapRow(array $row, string $basePath): array {
$id = (int) $row['id'];
$kind = (string) $row['kind'];
$provider = (string) ($row['provider'] ?? TicketAttachmentProvider::GENERIC);
$item = [
'id' => $id,
'ticket_id' => (int) $row['ticket_id'],
'kind' => $kind,
'title' => (string) ($row['title'] ?? ''),
'external_url' => (string) ($row['external_url'] ?? ''),
'provider' => $provider,
'provider_label' => TicketAttachmentProvider::labelFor($provider),
'original_name' => (string) ($row['original_name'] ?? ''),
'mime_type' => (string) ($row['mime_type'] ?? ''),
'size_bytes' => (int) ($row['size_bytes'] ?? 0),
'created_by' => (string) ($row['created_by'] ?? ''),
'created_at_ms' => (int) ($row['created_at_ms'] ?? 0),
'download_url' => $kind === 'file'
? $basePath . '/api/ticket_attachment.php?id=' . $id
: null,
];
return $item;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
final class TicketCommentRepository {
public static function listForTicket(int $ticketId): array {
// Do not call TicketRepository::getById here — mapDetailRow already loads comments.
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'SELECT id, ticket_id, author_username, body, created_at_ms, updated_at_ms
FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at_ms ASC, id ASC'
);
$stmt->execute([$ticketId]);
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[] = [
'id' => (int) $row['id'],
'ticket_id' => (int) $row['ticket_id'],
'author_username' => (string) $row['author_username'],
'body' => (string) $row['body'],
'created_at_ms' => (int) $row['created_at_ms'],
'updated_at_ms' => (int) $row['updated_at_ms'],
];
}
return $out;
}
public static function add(int $ticketId, string $author, string $body): array {
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
throw new RuntimeException('not found');
}
$body = trim($body);
if ($body === '') {
throw new InvalidArgumentException('comment body required');
}
$author = mb_substr(trim($author), 0, 64);
if ($author === '') {
throw new InvalidArgumentException('author required');
}
$now = (int) round(microtime(true) * 1000);
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'INSERT INTO ticket_comments (ticket_id, author_username, body, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$ticketId, $author, $body, $now, $now]);
$id = (int) $pdo->lastInsertId();
$pdo->prepare('UPDATE tickets SET updated_at_ms = ? WHERE id = ?')->execute([$now, $ticketId]);
return [
'id' => $id,
'ticket_id' => $ticketId,
'author_username' => $author,
'body' => $body,
'created_at_ms' => $now,
'updated_at_ms' => $now,
];
}
}

371
src/TicketRepository.php Normal file
View File

@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
final class TicketRepository {
private const LIST_SORT = [
'opened_at_ms',
'updated_at_ms',
'title',
'rating',
'app_version',
'device_model',
'owner_username',
];
private static ?array $ticketColumns = null;
public static function insert(array $payload): int {
$err = TicketTagCatalog::validate(is_array($payload['tags'] ?? null) ? $payload['tags'] : []);
if ($err !== null) {
throw new InvalidArgumentException($err);
}
$pdo = Database::pdo();
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
$tags = TicketTagCatalog::normalize($payload['tags'] ?? []);
$tagsJson = json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false || $tagsJson === false) {
throw new RuntimeException('json_encode failed');
}
$now = (int) round(microtime(true) * 1000);
$opened = (int) ($payload['opened_at_epoch_ms'] ?? $now);
$companyId = Rbac::defaultCompanyId();
$deviceId = DeviceRepository::upsertFromPayload($companyId, $device, 'auto');
$title = trim((string) ($payload['title'] ?? ''));
if ($title === '') {
throw new InvalidArgumentException('title required');
}
$brief = trim((string) ($payload['brief'] ?? ''));
$body = (string) ($payload['body'] ?? $brief);
$rating = max(0, min(5, (int) ($payload['rating'] ?? 0)));
$owner = trim((string) ($payload['owner'] ?? $payload['owner_username'] ?? ''));
$verified = trim((string) ($payload['verified_by'] ?? $payload['verified_by_username'] ?? ''));
$osName = device_os_name($device);
$osVer = ticket_format_os_version($device);
$stmt = $pdo->prepare(
'INSERT INTO tickets (company_id, device_id, ticket_id, title, brief, body,
opened_at_ms, created_at_ms, updated_at_ms, owner_username, verified_by_username,
device_model, app_package, app_version, os_name, os_version, rating, tags_json, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$companyId,
$deviceId > 0 ? $deviceId : null,
$payload['ticket_id'] ?? uniqid('tkt_', true),
mb_substr($title, 0, 256),
$brief !== '' ? $brief : null,
$body,
$opened,
$now,
$now,
mb_substr($owner, 0, 64),
$verified !== '' ? mb_substr($verified, 0, 64) : null,
$device['model'] ?? null,
$app['package'] ?? 'com.foxx.androidcast',
$app['version_name'] ?? null,
$osName !== '' ? $osName : null,
$osVer !== '' ? $osVer : null,
$rating,
$tagsJson,
$json,
]);
return (int) $pdo->lastInsertId();
}
public static function listPage(int $page, int $perPage, string $sort, string $dir, string $tagFilter = ''): array {
$pdo = Database::pdo();
$page = max(1, $page);
$perPage = max(1, min(200, $perPage));
$dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'opened_at_ms';
$offset = ($page - 1) * $perPage;
$where = ['1=1'];
$params = [];
self::appendCompanyScope('company_id', $where, $params);
if ($tagFilter !== '') {
$where[] = 'tags_json LIKE ?';
$params[] = '%"id":"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagFilter) . '"%';
}
$whereSql = implode(' AND ', $where);
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE $whereSql");
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$select = 'id, ticket_id, title, brief, opened_at_ms, updated_at_ms, created_at_ms,
owner_username, verified_by_username, device_model, app_package, app_version,
os_name, os_version, rating, tags_json';
if (self::hasTicketColumn('workflow_state')) {
$select .= ', workflow_state';
}
if (self::hasTicketColumn('assignees_json')) {
$select .= ', assignees_json';
}
$sql = "SELECT $select
FROM tickets WHERE $whereSql ORDER BY $sortCol $dir LIMIT $perPage OFFSET $offset";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = [];
foreach ($stmt->fetchAll() as $row) {
$items[] = self::mapListRow($row);
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'sort' => $sortCol,
'dir' => strtolower($dir),
];
}
public static function getById(int $id): ?array {
$pdo = Database::pdo();
$where = ['id = ?'];
$params = [$id];
self::appendCompanyScope('company_id', $where, $params);
$stmt = $pdo->prepare(
'SELECT * FROM tickets WHERE ' . implode(' AND ', $where) . ' LIMIT 1'
);
$stmt->execute($params);
$row = $stmt->fetch();
if (!$row) {
return null;
}
return self::mapDetailRow($row);
}
public static function setCustomTags(int $id, array $tagsIn): void {
$ticket = self::getById($id);
if (!$ticket) {
throw new RuntimeException('not found');
}
$err = TicketTagCatalog::validate($tagsIn);
if ($err !== null) {
throw new InvalidArgumentException($err);
}
$tags = TicketTagCatalog::normalize($tagsIn);
$now = (int) round(microtime(true) * 1000);
$pdo = Database::pdo();
$stmt = $pdo->prepare('UPDATE tickets SET tags_json = ?, updated_at_ms = ? WHERE id = ?');
$stmt->execute([
json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
$now,
$id,
]);
}
/** @param array<string, mixed> $fields */
public static function update(int $id, array $fields): void {
$ticket = self::getById($id);
if (!$ticket) {
throw new RuntimeException('not found');
}
$sets = [];
$params = [];
if (isset($fields['title'])) {
$title = trim((string) $fields['title']);
if ($title === '') {
throw new InvalidArgumentException('title required');
}
$sets[] = 'title = ?';
$params[] = mb_substr($title, 0, 256);
}
if (array_key_exists('brief', $fields)) {
$sets[] = 'brief = ?';
$params[] = trim((string) $fields['brief']) ?: null;
}
if (isset($fields['body'])) {
$sets[] = 'body = ?';
$params[] = (string) $fields['body'];
}
if (isset($fields['rating'])) {
$sets[] = 'rating = ?';
$params[] = max(0, min(5, (int) $fields['rating']));
}
if (isset($fields['owner_username'])) {
$sets[] = 'owner_username = ?';
$params[] = mb_substr(trim((string) $fields['owner_username']), 0, 64);
}
if (array_key_exists('verified_by_username', $fields)) {
$v = trim((string) $fields['verified_by_username']);
$sets[] = 'verified_by_username = ?';
$params[] = $v !== '' ? mb_substr($v, 0, 64) : null;
}
if (isset($fields['workflow_state']) && self::hasTicketColumn('workflow_state')) {
$state = TicketWorkflow::normalize((string) $fields['workflow_state']);
$sets[] = 'workflow_state = ?';
$params[] = $state;
$tags = TicketWorkflow::mergeTagsForState(
is_array($ticket['tags'] ?? null) ? $ticket['tags'] : [],
$state
);
$sets[] = 'tags_json = ?';
$params[] = json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
if (array_key_exists('assignees', $fields) && self::hasTicketColumn('assignees_json')) {
$assignees = self::normalizeAssignees($fields['assignees']);
$err = UserRepository::validateUsernames(
array_map(static fn (array $a): string => $a['username'], $assignees)
);
if ($err !== null) {
throw new InvalidArgumentException($err);
}
$json = json_encode($assignees, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new RuntimeException('json_encode failed');
}
$sets[] = 'assignees_json = ?';
$params[] = $json;
}
if ($sets === []) {
return;
}
$now = (int) round(microtime(true) * 1000);
$sets[] = 'updated_at_ms = ?';
$params[] = $now;
$params[] = $id;
$pdo = Database::pdo();
$stmt = $pdo->prepare('UPDATE tickets SET ' . implode(', ', $sets) . ' WHERE id = ?');
$stmt->execute($params);
}
public static function listTagPresets(): array {
return TicketTagCatalog::presets();
}
private static function appendCompanyScope(string $column, array &$where, array &$params): void {
$allowed = Rbac::allowedCompanyIds();
if ($allowed === null) {
return;
}
if ($allowed === []) {
$where[] = '0=1';
return;
}
$placeholders = implode(',', array_fill(0, count($allowed), '?'));
$where[] = "$column IN ($placeholders)";
foreach ($allowed as $id) {
$params[] = $id;
}
}
private static function hasTicketColumn(string $column): bool {
if (self::$ticketColumns === null) {
self::$ticketColumns = Database::columnNames(Database::pdo(), 'tickets');
}
return in_array($column, self::$ticketColumns, true);
}
/** @param array<string, mixed> $row */
private static function mapListRow(array $row): array {
$tags = parse_report_tags($row['tags_json'] ?? '[]');
$pkg = (string) ($row['app_package'] ?? 'com.foxx.androidcast');
$ver = (string) ($row['app_version'] ?? '');
$os = trim(((string) ($row['os_name'] ?? '')) . ' ' . ((string) ($row['os_version'] ?? '')));
return [
'id' => (int) $row['id'],
'ticket_id' => (string) $row['ticket_id'],
'title' => (string) $row['title'],
'brief' => (string) ($row['brief'] ?? ''),
'opened_at_ms' => (int) $row['opened_at_ms'],
'updated_at_ms' => (int) $row['updated_at_ms'],
'owner_username' => (string) ($row['owner_username'] ?? ''),
'verified_by_username' => (string) ($row['verified_by_username'] ?? ''),
'device_model' => (string) ($row['device_model'] ?? ''),
'app_package' => $pkg,
'app_version' => $ver,
'os_name' => (string) ($row['os_name'] ?? ''),
'os_version' => (string) ($row['os_version'] ?? ''),
'env_brief' => ticket_env_brief($pkg, $ver, (string) ($row['os_name'] ?? ''), (string) ($row['os_version'] ?? '')),
'rating' => (int) ($row['rating'] ?? 0),
'tags' => $tags,
'workflow_state' => TicketWorkflow::normalize((string) ($row['workflow_state'] ?? 'triage')),
'assignees' => self::parseAssigneesJson($row['assignees_json'] ?? '[]'),
];
}
/** @param array<string, mixed> $row */
private static function mapDetailRow(array $row): array {
$list = self::mapListRow($row);
$payload = json_decode($row['payload_json'] ?? '', true);
$list['body'] = (string) ($row['body'] ?? '');
$list['created_at_ms'] = (int) ($row['created_at_ms'] ?? 0);
$list['payload'] = is_array($payload) ? $payload : [];
$list['can_edit'] = Auth::canEditTicket($list);
try {
$list['comments'] = TicketCommentRepository::listForTicket((int) $row['id']);
} catch (Throwable $e) {
$list['comments'] = [];
}
try {
$list['attachments'] = TicketAttachmentRepository::listForTicket((int) $row['id']);
} catch (Throwable $e) {
$list['attachments'] = [];
}
return $list;
}
/** @return list<array{username:string,role:string}> */
public static function parseAssigneesJson(string $json): array {
$data = json_decode($json, true);
if (!is_array($data)) {
return [];
}
return self::normalizeAssignees($data);
}
/**
* @param mixed $raw
* @return list<array{username:string,role:string}>
*/
public static function normalizeAssignees($raw): array {
if (!is_array($raw)) {
return [];
}
$out = [];
$seen = [];
foreach ($raw as $item) {
if (is_string($item)) {
$item = ['username' => $item, 'role' => ''];
}
if (!is_array($item)) {
continue;
}
$user = trim((string) ($item['username'] ?? ''));
if ($user === '') {
continue;
}
$key = strtolower($user);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = [
'username' => mb_substr($user, 0, 64),
'role' => mb_substr(trim((string) ($item['role'] ?? '')), 0, 32),
];
}
return $out;
}
}
function ticket_format_os_version(array $device): string {
$rel = (string) ($device['release'] ?? '');
$sdk = (int) ($device['sdk_int'] ?? 0);
if ($rel !== '' && $sdk > 0) {
return $rel . ' (API ' . $sdk . ')';
}
if ($rel !== '') {
return $rel;
}
if ($sdk > 0) {
return 'API ' . $sdk;
}
return '';
}
function ticket_env_brief(string $pkg, string $version, string $osName, string $osVersion): string {
$app = str_contains($pkg, 'androidcast') ? 'Android Cast' : ($pkg !== '' ? $pkg : 'app');
$line = trim($app . ($version !== '' ? ' ' . $version : ''));
$os = trim($osName . ($osVersion !== '' ? ' · ' . $osVersion : ''));
return trim($line . ($os !== '' ? ' · ' . $os : ''));
}

109
src/TicketTagCatalog.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/** Workflow tags for tickets (must include exactly one meta tag: ticket or issue). */
final class TicketTagCatalog {
public const META_ID = 'ticket';
/** @var list<string> */
public const META_IDS = ['ticket', 'issue'];
/** @var array<string, array{label:string,bg:string}> */
private const META = [
'ticket' => ['label' => 'ticket', 'bg' => '#6366f1'],
'issue' => ['label' => 'issue', 'bg' => '#0d9488'],
];
/** @var array<string, array{label:string,bg:string}> */
private const STATUS = [
'open' => ['label' => 'open', 'bg' => '#22c55e'],
'in-progress' => ['label' => 'in progress', 'bg' => '#3b82f6'],
'blocked' => ['label' => 'blocked', 'bg' => '#b45309'],
'wont-fix' => ['label' => "won't fix", 'bg' => '#6b7280'],
'closed' => ['label' => 'closed', 'bg' => '#6b7280'],
'rejected' => ['label' => 'rejected', 'bg' => '#b91c1c'],
'confirmed' => ['label' => 'confirmed', 'bg' => '#047857'],
'postponed' => ['label' => 'postponed', 'bg' => '#a16207'],
'urgent' => ['label' => 'urgent', 'bg' => '#dc2626'],
];
/** @return list<string> */
public static function statusIds(): array {
return array_keys(self::STATUS);
}
/** @return list<array{id:string,label:string,bg:string}> */
public static function presets(): array {
$out = [];
foreach (self::META as $id => $def) {
$out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']];
}
foreach (self::STATUS as $id => $def) {
$out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']];
}
return $out;
}
public static function isMetaId(string $id): bool {
return isset(self::META[$id]);
}
/**
* @param list<mixed> $tags
* @return list<array{id:string,label:string,bg:string}>
*/
public static function normalize(array $tags): array {
$normalized = normalize_report_tags($tags);
$metaId = null;
$hasStatus = false;
$rest = [];
foreach ($normalized as $t) {
if (self::isMetaId($t['id'])) {
$metaId = $t['id'];
continue;
}
if (isset(self::STATUS[$t['id']])) {
$hasStatus = true;
}
$rest[] = $t;
}
if ($metaId === null) {
$metaId = self::META_ID;
}
$meta = self::META[$metaId];
$out = [
['id' => $metaId, 'label' => $meta['label'], 'bg' => $meta['bg']],
];
foreach ($rest as $t) {
$out[] = $t;
}
if (!$hasStatus) {
$out[] = ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'];
}
return $out;
}
public static function validate(array $tags): ?string {
$normalized = normalize_report_tags($tags);
$metaCount = 0;
$hasStatus = false;
foreach ($normalized as $t) {
if (self::isMetaId($t['id'])) {
$metaCount++;
}
if (isset(self::STATUS[$t['id']])) {
$hasStatus = true;
}
}
if ($metaCount === 0) {
return 'tags must include a type tag (ticket or issue)';
}
if ($metaCount > 1) {
return 'only one type tag allowed: ticket or issue';
}
if (!$hasStatus) {
return 'tags must include a status tag (open, in-progress, closed, …)';
}
return null;
}
}

84
src/TicketWorkflow.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/** Canonical lifecycle (separate from free-form tags). */
final class TicketWorkflow {
public const TRIAGE = 'triage';
public const IN_PROGRESS = 'in_progress';
public const BLOCKED = 'blocked';
public const WONT_FIX = 'wont_fix';
public const DONE = 'done';
public const CANCELLED = 'cancelled';
/** @return list<string> */
public static function all(): array {
return [
self::TRIAGE,
self::IN_PROGRESS,
self::BLOCKED,
self::WONT_FIX,
self::DONE,
self::CANCELLED,
];
}
public static function normalize(string $state): string {
$s = strtolower(trim($state));
return in_array($s, self::all(), true) ? $s : self::TRIAGE;
}
/** @return array{id:string,label:string,bg:string} */
public static function labelDef(string $state): array {
$map = [
self::TRIAGE => ['id' => self::TRIAGE, 'label' => 'triage', 'bg' => '#64748b'],
self::IN_PROGRESS => ['id' => self::IN_PROGRESS, 'label' => 'in progress', 'bg' => '#3b82f6'],
self::BLOCKED => ['id' => self::BLOCKED, 'label' => 'blocked', 'bg' => '#b45309'],
self::WONT_FIX => ['id' => self::WONT_FIX, 'label' => "won't fix", 'bg' => '#6b7280'],
self::DONE => ['id' => self::DONE, 'label' => 'done', 'bg' => '#047857'],
self::CANCELLED => ['id' => self::CANCELLED, 'label' => 'cancelled', 'bg' => '#b91c1c'],
];
return $map[self::normalize($state)] ?? $map[self::TRIAGE];
}
/** Map workflow to legacy status tag id for tag bar sync. */
public static function statusTagId(string $state): string {
return match (self::normalize($state)) {
self::IN_PROGRESS => 'in-progress',
self::BLOCKED => 'blocked',
self::WONT_FIX => 'wont-fix',
self::DONE => 'closed',
self::CANCELLED => 'rejected',
default => 'open',
};
}
/**
* Replace workflow/status tags; keep meta + custom tags.
*
* @param list<array{id:string,label:string,bg:string}> $tags
* @return list<array{id:string,label:string,bg:string}>
*/
public static function mergeTagsForState(array $tags, string $state): array {
$keep = [];
$statusIds = array_merge(TicketTagCatalog::statusIds(), TicketWorkflow::all());
foreach (normalize_report_tags($tags) as $t) {
if (TicketTagCatalog::isMetaId($t['id'])) {
$keep[] = $t;
continue;
}
if (in_array($t['id'], $statusIds, true)) {
continue;
}
$keep[] = $t;
}
$sid = self::statusTagId($state);
$preset = TicketTagCatalog::presets();
foreach ($preset as $p) {
if ($p['id'] === $sid) {
$keep[] = $p;
break;
}
}
return TicketTagCatalog::normalize($keep);
}
}