From 040e2fa9ca865086c330b84d32951c71f1a993ad Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 23 Jun 2026 12:29:32 +0200 Subject: [PATCH] initial --- README.md | 3 + composer.json | 25 ++ config/config.example.php | 2 + public/api/ticket_attachment.php | 43 +++ public/api/ticket_attachments.php | 109 +++++++ public/api/ticket_comments.php | 75 +++++ public/api/ticket_create.php | 65 ++++ public/api/ticket_tags.php | 75 +++++ public/api/ticket_update.php | 56 ++++ public/api/ticket_upload.php | 46 +++ public/api/tickets.php | 49 +++ public/api/users.php | 9 + public/index.php | 2 + sql/migrations/003_tickets.sql | 32 ++ sql/migrations/003_tickets.sqlite.sql | 27 ++ sql/migrations/004_ticket_workflow.sql | 18 ++ sql/migrations/005_ticket_attachments.sql | 20 ++ src/TicketAttachmentProvider.php | 62 ++++ src/TicketAttachmentRepository.php | 205 ++++++++++++ src/TicketCommentRepository.php | 58 ++++ src/TicketRepository.php | 371 ++++++++++++++++++++++ src/TicketTagCatalog.php | 109 +++++++ src/TicketWorkflow.php | 84 +++++ 23 files changed, 1545 insertions(+) create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/config.example.php create mode 100644 public/api/ticket_attachment.php create mode 100644 public/api/ticket_attachments.php create mode 100644 public/api/ticket_comments.php create mode 100644 public/api/ticket_create.php create mode 100644 public/api/ticket_tags.php create mode 100644 public/api/ticket_update.php create mode 100644 public/api/ticket_upload.php create mode 100644 public/api/tickets.php create mode 100644 public/api/users.php create mode 100644 public/index.php create mode 100644 sql/migrations/003_tickets.sql create mode 100644 sql/migrations/003_tickets.sqlite.sql create mode 100644 sql/migrations/004_ticket_workflow.sql create mode 100644 sql/migrations/005_ticket_attachments.sql create mode 100644 src/TicketAttachmentProvider.php create mode 100644 src/TicketAttachmentRepository.php create mode 100644 src/TicketCommentRepository.php create mode 100644 src/TicketRepository.php create mode 100644 src/TicketTagCatalog.php create mode 100644 src/TicketWorkflow.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..2df7bfd --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-ms-tickets + +Microservice API. Remote: `git://f0xx.org/ac/ac-ms-tickets` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a19c59e --- /dev/null +++ b/composer.json @@ -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/" + ] + } +} 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/ticket_attachment.php b/public/api/ticket_attachment.php new file mode 100644 index 0000000..350779a --- /dev/null +++ b/public/api/ticket_attachment.php @@ -0,0 +1,43 @@ + 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); +} diff --git a/public/api/ticket_comments.php b/public/api/ticket_comments.php new file mode 100644 index 0000000..e735708 --- /dev/null +++ b/public/api/ticket_comments.php @@ -0,0 +1,75 @@ + 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); +} diff --git a/public/api/ticket_create.php b/public/api/ticket_create.php new file mode 100644 index 0000000..81247b3 --- /dev/null +++ b/public/api/ticket_create.php @@ -0,0 +1,65 @@ + 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); +} diff --git a/public/api/ticket_tags.php b/public/api/ticket_tags.php new file mode 100644 index 0000000..21e9a06 --- /dev/null +++ b/public/api/ticket_tags.php @@ -0,0 +1,75 @@ + 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); +} diff --git a/public/api/ticket_update.php b/public/api/ticket_update.php new file mode 100644 index 0000000..fe34661 --- /dev/null +++ b/public/api/ticket_update.php @@ -0,0 +1,56 @@ + 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); +} diff --git a/public/api/ticket_upload.php b/public/api/ticket_upload.php new file mode 100644 index 0000000..b30c0b0 --- /dev/null +++ b/public/api/ticket_upload.php @@ -0,0 +1,46 @@ + 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); +} diff --git a/public/api/tickets.php b/public/api/tickets.php new file mode 100644 index 0000000..48912d0 --- /dev/null +++ b/public/api/tickets.php @@ -0,0 +1,49 @@ + 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); +} diff --git a/public/api/users.php b/public/api/users.php new file mode 100644 index 0000000..9bea9e0 --- /dev/null +++ b/public/api/users.php @@ -0,0 +1,9 @@ + false, 'error' => 'unauthorized'], 401); +} + +json_out(['ok' => true, 'users' => UserRepository::listForAssignees()]); 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 @@ + */ + 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; + } +} diff --git a/src/TicketAttachmentRepository.php b/src/TicketAttachmentRepository.php new file mode 100644 index 0000000..20a125b --- /dev/null +++ b/src/TicketAttachmentRepository.php @@ -0,0 +1,205 @@ + */ + 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 $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; + } +} diff --git a/src/TicketCommentRepository.php b/src/TicketCommentRepository.php new file mode 100644 index 0000000..1f82d80 --- /dev/null +++ b/src/TicketCommentRepository.php @@ -0,0 +1,58 @@ +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, + ]; + } +} diff --git a/src/TicketRepository.php b/src/TicketRepository.php new file mode 100644 index 0000000..05104a1 --- /dev/null +++ b/src/TicketRepository.php @@ -0,0 +1,371 @@ +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 $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 $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 $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 */ + 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 + */ + 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 : '')); +} diff --git a/src/TicketTagCatalog.php b/src/TicketTagCatalog.php new file mode 100644 index 0000000..1c9758e --- /dev/null +++ b/src/TicketTagCatalog.php @@ -0,0 +1,109 @@ + */ + public const META_IDS = ['ticket', 'issue']; + + /** @var array */ + private const META = [ + 'ticket' => ['label' => 'ticket', 'bg' => '#6366f1'], + 'issue' => ['label' => 'issue', 'bg' => '#0d9488'], + ]; + + /** @var array */ + 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 */ + public static function statusIds(): array { + return array_keys(self::STATUS); + } + + /** @return list */ + 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 $tags + * @return list + */ + 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; + } +} diff --git a/src/TicketWorkflow.php b/src/TicketWorkflow.php new file mode 100644 index 0000000..7954480 --- /dev/null +++ b/src/TicketWorkflow.php @@ -0,0 +1,84 @@ + */ + 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 $tags + * @return list + */ + 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); + } +}