mirror of
git://f0xx.org/ac/ac-ms-tickets
synced 2026-07-29 00:59:22 +03:00
initial
This commit is contained in:
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# ac-ms-tickets
|
||||
|
||||
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-tickets`
|
||||
25
composer.json
Normal file
25
composer.json
Normal 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/"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
config/config.example.php
Normal file
2
config/config.example.php
Normal file
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
return ['db' => ['driver' => 'sqlite']];
|
||||
43
public/api/ticket_attachment.php
Normal file
43
public/api/ticket_attachment.php
Normal 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);
|
||||
109
public/api/ticket_attachments.php
Normal file
109
public/api/ticket_attachments.php
Normal 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);
|
||||
}
|
||||
75
public/api/ticket_comments.php
Normal file
75
public/api/ticket_comments.php
Normal 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);
|
||||
}
|
||||
65
public/api/ticket_create.php
Normal file
65
public/api/ticket_create.php
Normal 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);
|
||||
}
|
||||
75
public/api/ticket_tags.php
Normal file
75
public/api/ticket_tags.php
Normal 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);
|
||||
}
|
||||
56
public/api/ticket_update.php
Normal file
56
public/api/ticket_update.php
Normal 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);
|
||||
}
|
||||
46
public/api/ticket_upload.php
Normal file
46
public/api/ticket_upload.php
Normal 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
49
public/api/tickets.php
Normal 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
9
public/api/users.php
Normal 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
2
public/index.php
Normal file
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// API entry — wire nginx to public/
|
||||
32
sql/migrations/003_tickets.sql
Normal file
32
sql/migrations/003_tickets.sql
Normal 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;
|
||||
27
sql/migrations/003_tickets.sqlite.sql
Normal file
27
sql/migrations/003_tickets.sqlite.sql
Normal 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);
|
||||
18
sql/migrations/004_ticket_workflow.sql
Normal file
18
sql/migrations/004_ticket_workflow.sql
Normal 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;
|
||||
20
sql/migrations/005_ticket_attachments.sql
Normal file
20
sql/migrations/005_ticket_attachments.sql
Normal 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;
|
||||
62
src/TicketAttachmentProvider.php
Normal file
62
src/TicketAttachmentProvider.php
Normal 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;
|
||||
}
|
||||
}
|
||||
205
src/TicketAttachmentRepository.php
Normal file
205
src/TicketAttachmentRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
58
src/TicketCommentRepository.php
Normal file
58
src/TicketCommentRepository.php
Normal 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
371
src/TicketRepository.php
Normal 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
109
src/TicketTagCatalog.php
Normal 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
84
src/TicketWorkflow.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user