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

next/side sync

This commit is contained in:
Anton Afanasyeu
2026-05-26 13:14:14 +02:00
parent ac8d4ea341
commit ef36f91b82
35 changed files with 1828 additions and 16 deletions

View File

@@ -0,0 +1,71 @@
# Landing + androidcast — BE only, port 80
**Full topology:** [docs/INFRA.md](../../docs/INFRA.md) — read after any agent/context reset.
Public: `https://apps.f0xx.org/app/androidcast_project/`
## Topology (no :8089)
```text
Browser → apps.f0xx.org (Gentoo FE, TLS)
→ http://artc0.intra.raptor.org:80 ← this vhost only
→ /etc/nginx/conf.d/apps.conf listen 80
```
**8089 is not this installation** (Janus/other). Do not point androidcast FE locations at :8089.
On FE, hub and (if you fix it) crashes should both target **`http://artc0.intra.raptor.org:80`**, same as `location /`.
## 1. `index.html`
```bash
cp .../android_cast/examples/app_hub/index.html \
.../htdocs/apps/app/androidcast_project/index.html
```
## 2. BE nginx
Edit **`/etc/nginx/conf.d/apps.conf`** on Alpine (sshfs: `tmp/BE_alpine/etc/nginx/...`).
Replace the whole **`server { listen 80; ... }`** block with:
**`examples/crash_reporter/backend/nginx.apps-port80.fragment`**
That file has hub + crashes + assets on **port 80**.
The **`listen 443 ssl`** block can stay for direct HTTPS to the VM if you use it internally; public traffic from FE never needs it for this app.
Use prefix location for the hub (not `alias` to a single file):
```nginx
location /app/androidcast_project/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
index index.html;
}
```
## 3. Reload BE
```bash
ssh alpine-be 'sudo nginx -t && sudo rc-service nginx reload'
```
## 4. Verify
```bash
curl -sS -o /dev/null -w "%{http_code}\n" -H "Host: apps.f0xx.org" \
"http://artc0.intra.raptor.org/app/androidcast_project/"
curl -sS -o /dev/null -w "%{http_code}\n" \
"https://apps.f0xx.org/app/androidcast_project/"
```
## FE (optional, not required if `location /` already proxies to :80)
If crashes on FE still say `proxy_pass https://artc0...:8089/.../crashes/`, change to:
```nginx
proxy_pass http://artc0.intra.raptor.org/app/androidcast_project/crashes/;
```
(same upstream as the hub — port **80**, no TLS to BE from FE)

View File

@@ -1,11 +1,8 @@
# Project hub
```bash
cp examples/app_hub/index.html /var/www/localhost/htdocs/apps/app/androidcast_project/
```
**Infra overview:** [docs/INFRA.md](../../docs/INFRA.md)
Nginx (`nginx.apps.conf.fragment`): serve that directory — **not** `alias …/index.html` (that causes 502).
**BE only.** FE `apps.f0xx.org` sends `location /``http://artc0.intra.raptor.org:80` (not :8089).
```bash
nginx -t && rc-service nginx reload
```
See **[DEPLOY.md](DEPLOY.md)** — edit `listen 80` in `tmp/BE_alpine/etc/nginx/conf.d/apps.conf` using
`examples/crash_reporter/backend/nginx.apps-port80.fragment`.

View File

@@ -0,0 +1,15 @@
# Replace the broken hub block in /etc/nginx/conf.d/apps.conf on Alpine BE.
# Remove duplicate "return 301 .../crashes/" and alias-to-index.html lines.
location = /app/androidcast_project {
return 301 /app/androidcast_project/;
}
location /app/androidcast_project/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
index index.html;
}
location ^~ /app/androidcast_project/crashes/assets/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
}

View File

@@ -94,6 +94,9 @@ curl -X POST http://127.0.0.1:8080/api/upload.php \
## Production (nginx + PHP-FPM)
**Deploy topology (FE/BE, ports, sshfs):** [docs/INFRA.md](../../../docs/INFRA.md).
**Canonical BE vhost for FE traffic:** `nginx.apps-port80.fragment` (`listen 80`). Port **8089** is not androidcast.
0. Ensure PHP modules above are installed for the FPM SAPI (not only CLI `php -m`).
1. Deploy the git tree so this path exists on the VM (no symlinks required):
`.../androidcast_project/android_cast/examples/crash_reporter/backend/`
@@ -187,6 +190,10 @@ php scripts/ingest_alpha_smoke_tickets.php --url=https://apps.f0xx.org/app/andro
MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql` or `./scripts/migrate-003-tickets.sh`. Fresh installs: included in `sql/schema.mariadb.sql` and `scripts/init-mariadb.sh`.
**Workflow / assignees / comments (004):** `mysql -u root -p androidcast_crashes < sql/migrations/004_ticket_workflow.sql` — see [docs/TICKETS_ROADMAP.md](../../../docs/TICKETS_ROADMAP.md).
**Attachments (005):** `mysql -u root -p androidcast_crashes < sql/migrations/005_ticket_attachments.sql` — files + external links (Google URLs auto-labeled).
## Default accounts
| User | Password | Role |

View File

@@ -0,0 +1,47 @@
# Full androidcast vhost for Alpine BE — listen 80 only.
# FE (apps.f0xx.org) proxies here: proxy_pass http://artc0.intra.raptor.org:80;
# Port 8089 is a different service (e.g. Janus) — not this vhost.
server {
listen 80;
location = /app/androidcast_project {
return 301 /app/androidcast_project/;
}
location /app/androidcast_project/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
index index.html;
}
location = /app/androidcast_project/crashes {
return 301 /app/androidcast_project/crashes/;
}
location ^~ /app/androidcast_project/crashes/assets/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
}
location = /app/androidcast_project/crashes/api/upload.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/upload.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/upload.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location ^~ /app/androidcast_project/crashes/ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location ~ \.php$ {
include /etc/nginx/fastcgi.conf;
fastcgi_pass unix:/run/php-fpm.socket;
}
}

View File

@@ -9,8 +9,8 @@
return 301 /app/androidcast_project/;
}
# Static hub: one index.html in project dir (directory alias — not alias-to-file)
location = /app/androidcast_project/ {
# Hub — live vhost is listen 80 on BE (see nginx.apps-port80.fragment). Not :8089.
location /app/androidcast_project/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
index index.html;
}

View File

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

View File

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

View File

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

View File

@@ -35,11 +35,14 @@ if (empty($ticket['can_edit'])) {
}
$fields = [];
foreach (['title', 'brief', 'body', 'rating', 'owner_username', 'verified_by_username'] as $key) {
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);

View File

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

View File

@@ -239,6 +239,46 @@ a:hover { text-decoration: underline; }
.ticket-issue-title { font-weight: 600; }
.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; }
.ticket-col-env { font-size: 0.9em; max-width: 220px; }
.assignee-editor { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
.assignee-pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px; background: var(--surface-2, #2a3344);
font-size: 0.9rem;
}
.assignee-remove {
border: none; background: transparent; color: var(--muted, #94a3b8);
cursor: pointer; font-size: 1.1rem; line-height: 1; padding: 0 2px;
}
.assignee-add-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.ticket-attachments { margin-top: 24px; }
.ticket-attachment-list { list-style: none; padding: 0; margin: 0 0 16px; }
.ticket-attachment {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
padding: 10px 12px; margin-bottom: 8px; border-radius: 8px;
background: var(--surface-2, #1e293b);
}
.attachment-provider {
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--muted, #94a3b8);
}
.ticket-attachment-add {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
}
@media (max-width: 720px) {
.ticket-attachment-add { grid-template-columns: 1fr; }
}
.btn-small { padding: 2px 8px; font-size: 0.85rem; min-height: auto; }
.ticket-comments { margin-top: 24px; }
.ticket-comment-list { list-style: none; padding: 0; margin: 0 0 16px; }
.ticket-comment {
padding: 12px 14px; margin-bottom: 10px; border-radius: 8px;
background: var(--surface-2, #1e293b);
}
.ticket-comment-meta { margin-bottom: 8px; display: flex; gap: 10px; flex-wrap: wrap; }
.ticket-comment-body {
margin: 0; white-space: pre-wrap; font-family: inherit; font-size: 0.95rem;
background: transparent; border: none; padding: 0;
}
.ticket-edit-form { display: grid; grid-template-columns: 1fr min(320px, 100%); gap: 16px; align-items: start; }
.ticket-edit-form .card--wide { grid-column: 1 / -1; }
@media (min-width: 900px) {

View File

@@ -139,5 +139,20 @@
"ticket.created": "Created",
"ticket.updated": "Updated",
"ticket.save": "Save ticket",
"ticket.source": "Source / context"
"ticket.source": "Source / context",
"ticket.lifecycle": "Lifecycle",
"ticket.assignees": "Assignees",
"ticket.assignee_add": "Add assignee",
"ticket.comments": "Comments",
"ticket.comment_add": "Add comment",
"ticket.comment_post": "Post comment",
"ticket.attachments": "Attachments",
"ticket.attach_link": "Add link",
"ticket.attach_link_hint": "Google Docs, Sheets, Drive, Meet, or any HTTPS URL.",
"ticket.attach_link_btn": "Attach link",
"ticket.attach_file": "Upload file",
"ticket.attach_file_hint": "Max 16 MiB — images, logs, pdf, zip, …",
"ticket.attach_file_btn": "Upload",
"ticket.link_title": "Title (optional)",
"ticket.file_pick": "File"
}

View File

@@ -115,5 +115,21 @@
"login.register": "Регистрация",
"login.register_soon": "(скоро)",
"login.error": "Неверные учётные данные",
"footer.copyright": "(c) Anton Afanaasyeu, {year}"
"footer.copyright": "(c) Anton Afanaasyeu, {year}",
"nav.tickets": "Тикеты",
"ticket.lifecycle": "Жизненный цикл",
"ticket.assignees": "Исполнители",
"ticket.assignee_add": "Добавить",
"ticket.comments": "Комментарии",
"ticket.comment_add": "Новый комментарий",
"ticket.comment_post": "Отправить",
"ticket.attachments": "Вложения",
"ticket.attach_link": "Ссылка",
"ticket.attach_link_hint": "Google Docs, Таблицы, Drive, Meet или любой HTTPS URL.",
"ticket.attach_link_btn": "Прикрепить",
"ticket.attach_file": "Файл",
"ticket.attach_file_hint": "До 16 МБ — изображения, логи, pdf, zip…",
"ticket.attach_file_btn": "Загрузить",
"ticket.link_title": "Название (необяз.)",
"ticket.file_pick": "Файл"
}

View File

@@ -1461,6 +1461,297 @@
if (!id) return;
const tree = document.getElementById('detail-tree');
if (tree) bindDetailTree(tree);
let assignees = [];
const assigneeDataEl = document.getElementById('ticket-assignees-data');
if (assigneeDataEl) {
try {
assignees = JSON.parse(assigneeDataEl.textContent || '[]');
if (!Array.isArray(assignees)) assignees = [];
} catch {
assignees = [];
}
}
const assigneeEditor = document.getElementById('assignee-editor');
const assigneePick = document.getElementById('assignee-user-pick');
const assigneeAddBtn = document.getElementById('assignee-add-btn');
function renderAssignees() {
if (!assigneeEditor) return;
if (!assignees.length) {
assigneeEditor.innerHTML = '<span class="muted">—</span>';
return;
}
assigneeEditor.innerHTML = assignees
.map(function (a, idx) {
const role = a.role ? ' <span class="muted">(' + escapeHtml(a.role) + ')</span>' : '';
return (
'<span class="assignee-pill">' +
escapeHtml(a.username) +
role +
' <button type="button" class="assignee-remove" data-idx="' +
idx +
'" aria-label="Remove">×</button></span>'
);
})
.join('');
assigneeEditor.querySelectorAll('.assignee-remove').forEach(function (btn) {
btn.addEventListener('click', function () {
const i = Number(btn.getAttribute('data-idx'));
assignees.splice(i, 1);
renderAssignees();
});
});
}
function loadUsersForAssignees() {
if (!assigneePick) return;
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/users.php', true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (!data.ok || !Array.isArray(data.users)) return;
data.users.forEach(function (u) {
const opt = document.createElement('option');
opt.value = u.username;
opt.textContent = u.username + (u.role ? ' (' + u.role + ')' : '');
opt.dataset.role = u.role || '';
assigneePick.appendChild(opt);
});
};
xhr.send();
}
if (assigneeEditor) {
renderAssignees();
loadUsersForAssignees();
if (assigneeAddBtn && assigneePick) {
assigneeAddBtn.addEventListener('click', function () {
const username = assigneePick.value;
if (!username) return;
const role = assigneePick.selectedOptions[0]?.dataset.role || '';
if (assignees.some(function (a) { return a.username === username; })) return;
assignees.push({ username: username, role: role });
renderAssignees();
});
}
}
const attachmentList = document.getElementById('ticket-attachment-list');
const linkForm = document.getElementById('ticket-link-form');
const fileForm = document.getElementById('ticket-file-form');
const linkStatus = document.getElementById('ticket-link-status');
const fileStatus = document.getElementById('ticket-file-status');
function renderAttachmentItem(att) {
const li = document.createElement('li');
li.className = 'ticket-attachment ticket-attachment--' + (att.kind || 'file');
li.dataset.attachmentId = String(att.id);
let main = '';
const label = escapeHtml(att.provider_label || 'File');
if (att.kind === 'link' && att.external_url) {
main =
'<a href="' +
escapeHtml(att.external_url) +
'" target="_blank" rel="noopener noreferrer">' +
escapeHtml(att.title || att.external_url) +
'</a>';
} else if (att.download_url) {
const size =
att.size_bytes > 0
? ' <span class="muted">(' + (att.size_bytes / 1024).toFixed(1) + ' KB)</span>'
: '';
main =
'<a href="' +
escapeHtml(att.download_url) +
'">' +
escapeHtml(att.title || att.original_name || 'file') +
'</a>' +
size;
} else {
main = '<span>' + escapeHtml(att.title || '') + '</span>';
}
li.innerHTML =
'<span class="attachment-provider">' +
label +
'</span> ' +
main +
' <span class="muted attachment-meta">' +
escapeHtml(att.created_by || '') +
'</span>' +
' <button type="button" class="btn btn-small attachment-remove" data-id="' +
att.id +
'">×</button>';
li.querySelector('.attachment-remove').addEventListener('click', function () {
if (!confirm('Remove attachment?')) return;
const xhr = new XMLHttpRequest();
xhr.open('DELETE', basePath() + '/api/ticket_attachments.php?id=' + att.id, true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (data.ok) li.remove();
};
xhr.send();
});
return li;
}
if (linkForm) {
linkForm.addEventListener('submit', function (e) {
e.preventDefault();
const fd = new FormData(linkForm);
const url = String(fd.get('url') || '').trim();
if (!url) return;
if (linkStatus) linkStatus.textContent = 'Saving…';
const xhr = new XMLHttpRequest();
xhr.open('POST', basePath() + '/api/ticket_attachments.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
if (linkStatus) linkStatus.textContent = 'Invalid response';
return;
}
if (!data.ok) {
if (linkStatus) linkStatus.textContent = data.error || 'Failed';
return;
}
if (attachmentList && data.attachment) {
attachmentList.appendChild(renderAttachmentItem(data.attachment));
}
linkForm.reset();
if (linkStatus) linkStatus.textContent = 'Attached';
};
xhr.onerror = function () {
if (linkStatus) linkStatus.textContent = t('reports.network_error');
};
xhr.send(
JSON.stringify({
ticket_id: Number(id),
title: fd.get('title'),
url: url,
})
);
});
}
if (fileForm) {
fileForm.addEventListener('submit', function (e) {
e.preventDefault();
const fd = new FormData(fileForm);
fd.append('ticket_id', id);
if (fileStatus) fileStatus.textContent = 'Uploading…';
const xhr = new XMLHttpRequest();
xhr.open('POST', basePath() + '/api/ticket_attachments.php', true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
if (fileStatus) fileStatus.textContent = 'Invalid response';
return;
}
if (!data.ok) {
if (fileStatus) fileStatus.textContent = data.error || 'Failed';
return;
}
if (attachmentList && data.attachment) {
attachmentList.appendChild(renderAttachmentItem(data.attachment));
}
fileForm.reset();
if (fileStatus) fileStatus.textContent = 'Uploaded';
};
xhr.onerror = function () {
if (fileStatus) fileStatus.textContent = t('reports.network_error');
};
xhr.send(fd);
});
}
if (attachmentList) {
attachmentList.querySelectorAll('.attachment-remove').forEach(function (btn) {
btn.addEventListener('click', function () {
const attId = btn.getAttribute('data-id');
if (!confirm('Remove attachment?')) return;
const xhr = new XMLHttpRequest();
xhr.open('DELETE', basePath() + '/api/ticket_attachments.php?id=' + attId, true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (data.ok) btn.closest('.ticket-attachment')?.remove();
};
xhr.send();
});
});
}
const commentForm = document.getElementById('ticket-comment-form');
const commentStatus = document.getElementById('ticket-comment-status');
const commentList = document.getElementById('ticket-comment-list');
if (commentForm) {
commentForm.addEventListener('submit', function (e) {
e.preventDefault();
const fd = new FormData(commentForm);
const bodyText = String(fd.get('body') || '').trim();
if (!bodyText) return;
if (commentStatus) commentStatus.textContent = 'Posting…';
const xhr = new XMLHttpRequest();
xhr.open('POST', basePath() + '/api/ticket_comments.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
if (commentStatus) commentStatus.textContent = 'Invalid response';
return;
}
if (!data.ok) {
if (commentStatus) commentStatus.textContent = data.error || 'Failed';
return;
}
const c = data.comment;
if (commentList && c) {
const li = document.createElement('li');
li.className = 'ticket-comment';
const when = c.created_at_ms
? new Date(Number(c.created_at_ms)).toISOString().replace('T', ' ').slice(0, 19)
: '';
li.innerHTML =
'<div class="ticket-comment-meta"><strong>' +
escapeHtml(c.author_username) +
'</strong> <span class="muted">' +
escapeHtml(when) +
'</span></div><pre class="ticket-comment-body"></pre>';
li.querySelector('.ticket-comment-body').textContent = c.body;
commentList.appendChild(li);
}
commentForm.reset();
if (commentStatus) commentStatus.textContent = 'Posted';
};
xhr.onerror = function () {
if (commentStatus) commentStatus.textContent = t('reports.network_error');
};
xhr.send(JSON.stringify({ ticket_id: Number(id), body: bodyText }));
});
}
const form = document.getElementById('ticket-edit-form');
if (!form) return;
const statusEl = document.getElementById('ticket-save-status');
@@ -1488,6 +1779,8 @@
owner_username: fd.get('owner_username'),
verified_by_username: fd.get('verified_by_username'),
rating: Number(fd.get('rating')),
workflow_state: fd.get('workflow_state'),
assignees: assignees,
};
const xhr = new XMLHttpRequest();
xhr.open('PUT', basePath() + '/api/ticket_update.php', true);

View File

@@ -65,6 +65,26 @@ if ($route === '/api/ticket_update.php' || str_ends_with($route, '/api/ticket_up
exit;
}
if ($route === '/api/ticket_comments.php' || str_ends_with($route, '/api/ticket_comments.php')) {
require __DIR__ . '/api/ticket_comments.php';
exit;
}
if ($route === '/api/users.php' || str_ends_with($route, '/api/users.php')) {
require __DIR__ . '/api/users.php';
exit;
}
if ($route === '/api/ticket_attachments.php' || str_ends_with($route, '/api/ticket_attachments.php')) {
require __DIR__ . '/api/ticket_attachments.php';
exit;
}
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
require __DIR__ . '/api/ticket_attachment.php';
exit;
}
if ($route === '/logout') {
Auth::logout();
header('Location: ' . $base . '/login');

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Sync backend code to BE sshfs mount (skip data/ — permissions).
# Usage: ./scripts/deploy-to-be-mount.sh [DEST]
set -euo pipefail
SRC="$(cd "$(dirname "$0")/.." && pwd)"
DEST="${1:-$(cd "$SRC/../../../tmp/BE_alpine/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend" 2>/dev/null && pwd || true)}"
if [[ -z "$DEST" || ! -d "$DEST" ]]; then
echo "DEST not found. Pass BE mount path as first argument." >&2
exit 1
fi
rsync -a --no-group --no-owner \
--exclude 'data/' --exclude 'config/config.php' \
"$SRC/" "$DEST/"
echo "Synced to $DEST (config.php and data/ untouched)."

View File

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

View File

@@ -0,0 +1,13 @@
ALTER TABLE tickets ADD COLUMN workflow_state TEXT NOT NULL DEFAULT 'triage';
ALTER TABLE tickets ADD COLUMN assignees_json TEXT NOT NULL DEFAULT '[]';
CREATE TABLE IF NOT EXISTS ticket_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
author_username TEXT NOT NULL,
body TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ticket_comments_ticket ON ticket_comments (ticket_id);

View File

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

View File

@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS ticket_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'link')),
title TEXT NOT NULL DEFAULT '',
external_url TEXT NULL,
provider TEXT NOT NULL DEFAULT 'generic',
storage_name TEXT NULL,
original_name TEXT NULL,
mime_type TEXT NULL,
size_bytes INTEGER NULL,
created_by TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ticket_attachments_ticket ON ticket_attachments (ticket_id);

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
final class TicketCommentRepository {
public static function listForTicket(int $ticketId): array {
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
return [];
}
$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,
];
}
}

View File

@@ -90,7 +90,7 @@ final class TicketRepository {
$total = (int) $countStmt->fetchColumn();
$sql = "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
os_name, os_version, rating, tags_json, workflow_state, assignees_json
FROM tickets WHERE $whereSql ORDER BY $sortCol $dir LIMIT $perPage OFFSET $offset";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
@@ -181,6 +181,32 @@ final class TicketRepository {
$sets[] = 'verified_by_username = ?';
$params[] = $v !== '' ? mb_substr($v, 0, 64) : null;
}
if (isset($fields['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)) {
$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;
}
@@ -236,6 +262,8 @@ final class TicketRepository {
'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'] ?? '[]'),
];
}
@@ -247,8 +275,61 @@ final class TicketRepository {
$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 {

View File

@@ -9,6 +9,8 @@ final class TicketTagCatalog {
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'],

View File

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

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
final class UserRepository {
/** @return list<array{username:string,role:string}> */
public static function listForAssignees(): array {
$pdo = Database::pdo();
$stmt = $pdo->query('SELECT username, role FROM users ORDER BY username ASC');
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[] = [
'username' => (string) $row['username'],
'role' => (string) ($row['role'] ?? 'viewer'),
];
}
return $out;
}
/** @param list<string> $usernames */
public static function validateUsernames(array $usernames): ?string {
$want = [];
foreach ($usernames as $u) {
$u = trim((string) $u);
if ($u !== '') {
$want[strtolower($u)] = $u;
}
}
if ($want === []) {
return null;
}
$pdo = Database::pdo();
$stmt = $pdo->query('SELECT username FROM users');
$have = [];
foreach ($stmt->fetchAll() as $row) {
$have[strtolower((string) $row['username'])] = true;
}
foreach ($want as $lower => $orig) {
if (!isset($have[$lower])) {
return 'unknown user: ' . $orig;
}
}
return null;
}
}

View File

@@ -22,6 +22,11 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
}
}
$rating = (int) ($ticket['rating'] ?? 0);
$workflowState = TicketWorkflow::normalize((string) ($ticket['workflow_state'] ?? 'triage'));
$assignees = is_array($ticket['assignees'] ?? null) ? $ticket['assignees'] : [];
$comments = is_array($ticket['comments'] ?? null) ? $ticket['comments'] : [];
$attachments = is_array($ticket['attachments'] ?? null) ? $ticket['attachments'] : [];
$canAttach = (bool) Auth::user();
$starsHtml = '';
for ($i = 1; $i <= 5; $i++) {
$starsHtml .= '<span class="star' . ($i <= $rating ? ' star--on' : '') . '" aria-hidden="true"></span>';
@@ -106,6 +111,26 @@ for ($i = 1; $i <= 5; $i++) {
<input type="text" name="verified_by_username" maxlength="64"
value="<?= h($ticket['verified_by_username'] ?? '') ?>">
</label>
<label class="ticket-field">
<span data-i18n="ticket.lifecycle">Lifecycle</span>
<select name="workflow_state" id="ticket-workflow-state">
<?php foreach (TicketWorkflow::all() as $ws): ?>
<option value="<?= h($ws) ?>"<?= $workflowState === $ws ? ' selected' : '' ?>>
<?= h(TicketWorkflow::labelDef($ws)['label']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<div class="ticket-field ticket-assignees-field">
<span data-i18n="ticket.assignees">Assignees</span>
<div class="assignee-editor" id="assignee-editor" aria-live="polite"></div>
<div class="assignee-add-row">
<select id="assignee-user-pick" aria-label="Add assignee">
<option value="">—</option>
</select>
<button type="button" class="btn" id="assignee-add-btn" data-i18n="ticket.assignee_add">Add</button>
</div>
</div>
<label class="ticket-field">
<span data-i18n="col.rating">Rating</span>
<select name="rating">
@@ -123,6 +148,9 @@ for ($i = 1; $i <= 5; $i++) {
</div>
</div>
</form>
<script type="application/json" id="ticket-assignees-data"><?=
safe_json_encode($assignees, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
?></script>
<?php else: ?>
<section class="stack-block ticket-readonly-issue">
<h2><?= h($ticket['title'] ?? '') ?></h2>
@@ -164,6 +192,18 @@ for ($i = 1; $i <= 5; $i++) {
</div>
<?php if (!$canEdit): ?>
<div class="card card--lift"><h3 data-i18n="ticket.workflow">Workflow</h3>
<p><span data-i18n="ticket.lifecycle">Lifecycle</span>:
<?= h(TicketWorkflow::labelDef($workflowState)['label']) ?></p>
<p><span data-i18n="ticket.assignees">Assignees</span>:
<?php if ($assignees === []): ?>
<span class="muted">—</span>
<?php else: ?>
<?php foreach ($assignees as $a): ?>
<span class="assignee-pill"><?= h($a['username']) ?><?php if (!empty($a['role'])): ?>
<span class="muted">(<?= h($a['role']) ?>)</span><?php endif; ?></span>
<?php endforeach; ?>
<?php endif; ?>
</p>
<p><span data-i18n="ticket.owner">Owner</span>: <?= h($ticket['owner_username'] ?? '—') ?></p>
<p><span data-i18n="ticket.verified_by">Verified by</span>:
<?= h($ticket['verified_by_username'] ?? '—') ?></p>
@@ -173,6 +213,89 @@ for ($i = 1; $i <= 5; $i++) {
<?php endif; ?>
</div>
<section class="ticket-attachments" id="ticket-attachments" data-ticket-id="<?= $ticketDbId ?>">
<h2 data-i18n="ticket.attachments">Attachments</h2>
<ul class="ticket-attachment-list" id="ticket-attachment-list">
<?php foreach ($attachments as $att): ?>
<li class="ticket-attachment ticket-attachment--<?= h($att['kind']) ?>"
data-attachment-id="<?= (int) ($att['id'] ?? 0) ?>">
<span class="attachment-provider"><?= h($att['provider_label'] ?? 'File') ?></span>
<?php if (($att['kind'] ?? '') === 'link' && !empty($att['external_url'])): ?>
<a href="<?= h($att['external_url']) ?>" target="_blank" rel="noopener noreferrer"><?= h($att['title'] ?: $att['external_url']) ?></a>
<?php elseif (!empty($att['download_url'])): ?>
<a href="<?= h($att['download_url']) ?>"><?= h($att['title'] ?: $att['original_name']) ?></a>
<?php if (!empty($att['size_bytes'])): ?>
<span class="muted">(<?= h(number_format((int) $att['size_bytes'] / 1024, 1)) ?> KB)</span>
<?php endif; ?>
<?php else: ?>
<span><?= h($att['title'] ?? '') ?></span>
<?php endif; ?>
<span class="muted attachment-meta"><?= h($att['created_by'] ?? '') ?></span>
<?php if ($canAttach): ?>
<button type="button" class="btn btn-small attachment-remove" data-id="<?= (int) ($att['id'] ?? 0) ?>">×</button>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php if ($canAttach): ?>
<div class="ticket-attachment-add cards cards--lift">
<form class="ticket-link-form" id="ticket-link-form">
<h3 data-i18n="ticket.attach_link">Add link</h3>
<p class="muted" data-i18n="ticket.attach_link_hint">Google Docs, Sheets, Drive, Meet, or any HTTPS URL.</p>
<label class="ticket-field">
<span data-i18n="ticket.link_title">Title (optional)</span>
<input type="text" name="title" maxlength="256">
</label>
<label class="ticket-field">
<span>URL</span>
<input type="url" name="url" required placeholder="https://docs.google.com/...">
</label>
<button type="submit" class="btn" data-i18n="ticket.attach_link_btn">Attach link</button>
<span class="muted" id="ticket-link-status"></span>
</form>
<form class="ticket-file-form" id="ticket-file-form" enctype="multipart/form-data">
<h3 data-i18n="ticket.attach_file">Upload file</h3>
<p class="muted" data-i18n="ticket.attach_file_hint">Max 16 MiB — images, logs, pdf, zip, …</p>
<label class="ticket-field">
<span data-i18n="ticket.link_title">Title (optional)</span>
<input type="text" name="title" maxlength="256">
</label>
<label class="ticket-field">
<span data-i18n="ticket.file_pick">File</span>
<input type="file" name="file" required>
</label>
<button type="submit" class="btn btn-primary" data-i18n="ticket.attach_file_btn">Upload</button>
<span class="muted" id="ticket-file-status"></span>
</form>
</div>
<?php endif; ?>
</section>
<section class="ticket-comments" id="ticket-comments" data-ticket-id="<?= $ticketDbId ?>">
<h2 data-i18n="ticket.comments">Comments</h2>
<ul class="ticket-comment-list" id="ticket-comment-list">
<?php foreach ($comments as $c): ?>
<li class="ticket-comment">
<div class="ticket-comment-meta">
<strong><?= h($c['author_username']) ?></strong>
<span class="muted"><?= h(date('c', (int) (($c['created_at_ms'] ?? 0) / 1000))) ?></span>
</div>
<pre class="ticket-comment-body"><?= h($c['body']) ?></pre>
</li>
<?php endforeach; ?>
</ul>
<?php if ($canEdit || Auth::user()): ?>
<form class="ticket-comment-form" id="ticket-comment-form">
<label class="ticket-field">
<span data-i18n="ticket.comment_add">Add comment</span>
<textarea name="body" rows="4" required></textarea>
</label>
<button type="submit" class="btn btn-primary" data-i18n="ticket.comment_post">Post</button>
<span class="muted" id="ticket-comment-status"></span>
</form>
<?php endif; ?>
</section>
<div class="detail-tree" id="detail-tree">
<?php if (!empty($p['source_file']) || !empty($p['alpha_block'])): ?>
<div class="detail-tree-item">