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

Merge branch '20260526_sync' into next

This commit is contained in:
Anton Afanasyeu
2026-05-26 16:11:36 +02:00
35 changed files with 1828 additions and 16 deletions

10
AGENTS.md Normal file
View File

@@ -0,0 +1,10 @@
# Cursor / agent bootstrap
After a **reset** or new session on deploy/infra work:
1. Read **[docs/INFRA.md](docs/INFRA.md)** end-to-end (topology, **no :8089** for androidcast, BE `listen 80`, sshfs `tmp/` paths).
2. For hub deploy only: **[examples/app_hub/DEPLOY.md](examples/app_hub/DEPLOY.md)**.
3. For PHP/DB/tickets: **[examples/crash_reporter/backend/README.md](examples/crash_reporter/backend/README.md)**.
4. For git branches: **[docs/GIT_FLOW.md](docs/GIT_FLOW.md)** — work on `feature/*` from `next`; do not commit unless asked.
**Hard rule:** Port **8089** is **not** the androidcast vhost (Janus/other). Public apps path is **`https://apps.f0xx.org`** → BE **`http://artc0.intra.raptor.org:80`**.

View File

@@ -1,6 +1,6 @@
# Android Cast (POC)
**Roadmap & alpha:** [docs/ROADMAP.md](docs/ROADMAP.md) · **Alpha QA & feature freeze:** [docs/ALPHA.md](docs/ALPHA.md) · USB-C/HDMI: [docs/USB_HDMI_CAST.md](docs/USB_HDMI_CAST.md) · Commercial (dev-gated): [docs/COMMERCIAL.md](docs/COMMERCIAL.md)
**Roadmap & alpha:** [docs/ROADMAP.md](docs/ROADMAP.md) · **Alpha QA & feature freeze:** [docs/ALPHA.md](docs/ALPHA.md) · **Production infra (agents: read first):** [docs/INFRA.md](docs/INFRA.md) · USB-C/HDMI: [docs/USB_HDMI_CAST.md](docs/USB_HDMI_CAST.md) · Commercial (dev-gated): [docs/COMMERCIAL.md](docs/COMMERCIAL.md)
Minimal LAN screen-casting proof of concept in **Java**. Captures screen + playback audio, encodes **H.264 + AAC**, streams over **TCP or UDP** to a receiver that decodes and plays back.

View File

@@ -22,7 +22,8 @@ Deploy tuning via app-private `files/settings.json` (see `examples/settings.json
## Backend
PHP console and ingest API: `examples/crash_reporter/backend/` — see [README](../examples/crash_reporter/backend/README.md).
PHP console and ingest API: `examples/crash_reporter/backend/` — see [README](../examples/crash_reporter/backend/README.md).
Production nginx/hosts: [INFRA.md](INFRA.md).
Roadmap: `examples/crash_reporter/ROADMAP.md`.

202
docs/INFRA.md Normal file
View File

@@ -0,0 +1,202 @@
# Android Cast — production infrastructure
**Read this first** after a context reset, before changing nginx, deploy paths, or URLs.
| Doc | When |
|-----|------|
| **This file** | Topology, hosts, ports, sshfs, agent rules |
| [examples/app_hub/DEPLOY.md](../examples/app_hub/DEPLOY.md) | Hub landing only (BE `listen 80`) |
| [examples/crash_reporter/backend/README.md](../examples/crash_reporter/backend/README.md) | PHP, MariaDB, tickets, ingest |
| [docs/GIT_FLOW.md](GIT_FLOW.md) | Branches, `next`, no direct commits to `master` |
| [docs/TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) | Tickets, Gitea, alpha delegation |
---
## 1. Topology
```text
Internet / LAN
┌─────────────────────────────────────┐
│ FE — Gentoo HVM │
│ Public: https://apps.f0xx.org │
│ TLS termination, nginx reverse proxy│
└──────────────┬──────────────────────┘
│ proxy_pass http://artc0.intra.raptor.org:80
│ (default location /)
┌─────────────────────────────────────┐
│ BE — Alpine VM 10.7.16.128 │
│ nginx: /etc/nginx/conf.d/apps.conf│
│ PHP-FPM unix socket │
│ MariaDB: androidcast_crashes │
└─────────────────────────────────────┘
```
| Role | Host | Notes |
|------|------|--------|
| **FE** | Gentoo, `apps.f0xx.org` | Almost all app traffic → BE **:80** |
| **BE** | Alpine `artc0.intra.raptor.org` | App vhost lives in **`listen 80`** for FE traffic |
| **Jump SSH** | `f0xx.org:222` → Alpine `:22` | `~/.ssh/config` host **`alpine-be`** |
| **Git origin** | `git://f0xx.org/android_cast` | Integration branch **`next`** |
---
## 2. Port 8089 — not the androidcast vhost
**Do not** configure or document the project hub or crash console on **`:8089`** for this installation.
| Port | Service |
|------|---------|
| **80** | **Canonical** androidcast vhost on BE (hub + crashes when FE uses `location /` or explicit `:80` upstream) |
| **443** | BE TLS (used by some FE `proxy_pass` rules, e.g. `apps.f0xx.org``https://artc0:443/.../crashes/`) |
| **8089** | **Other** stack (e.g. **Janus** `/janus/`) — **not** androidcast nginx vhost |
Wrong FE pattern (remove if present on **`artc0.f0xx.org`**):
```nginx
proxy_pass https://artc0.intra.raptor.org:8089/app/androidcast_project/; # BAD for hub
```
Correct public path for hub:
`https://apps.f0xx.org/app/androidcast_project/` → FE `location /``http://artc0.intra.raptor.org:80` → BE `listen 80` locations.
---
## 3. Public URLs
| URL | Component |
|-----|-----------|
| `https://apps.f0xx.org/app/androidcast_project/` | Static hub (`examples/app_hub/index.html`) |
| `https://apps.f0xx.org/app/androidcast_project/crashes/` | PHP crash + tickets console |
| `https://apps.f0xx.org/app/androidcast_project/crashes/api/upload.php` | Device crash upload (no session) |
Legacy hostname **`f0xx.org`** may still appear in app `settings.json` examples; production ingest/scripts prefer **`apps.f0xx.org`**.
---
## 4. Disk layout (BE)
Web root prefix:
`/var/www/localhost/htdocs/apps/app/androidcast_project/`
| Path on BE | Repo |
|------------|------|
| `.../androidcast_project/android_cast/` | Full git tree (crash backend, app sources) |
| `.../androidcast_project/index.html` | `examples/app_hub/index.html` (copy or symlink) |
| `.../android_cast/examples/crash_reporter/backend/public/` | PHP document root for crashes |
`config.php` **`base_path`** must be exactly:
`/app/androidcast_project/crashes`
---
## 5. Nginx — source of truth in repo
| File | Purpose |
|------|---------|
| **`examples/crash_reporter/backend/nginx.apps-port80.fragment`** | **Replace** BE `server { listen 80; ... }` — hub + crashes + assets |
| `examples/crash_reporter/backend/nginx.apps.conf.fragment` | Reference blocks (443-oriented comments) |
| `examples/crash_reporter/backend/nginx.vm.conf` | Full VM example |
### BE `apps.conf` today (typical)
- **`listen 80`** — often **empty** (only generic `\.php$`) → **public hub 502** until port-80 fragment is applied.
- **`listen 443 ssl`** — may already contain hub + crashes (direct HTTPS to VM).
FE **`apps.f0xx.org`** uses `proxy_pass http://artc0.intra.raptor.org:80` for `/` — so **hub must be on BE port 80**, not only 443.
### Hub location (correct)
Use **directory** `alias`, not `alias` to a single file:
```nginx
location /app/androidcast_project/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
index index.html;
}
```
### Reload (BE only for hub deploy)
```bash
ssh alpine-be 'sudo nginx -t && sudo rc-service nginx reload'
```
---
## 6. Local sshfs workspace (not in git)
Edit live configs from the dev machine via mounts under repo **`tmp/`**:
| Mount | Remote |
|-------|--------|
| `tmp/FE_gentoo/etc/nginx/` | Gentoo FE nginx |
| `tmp/BE_alpine/etc/nginx/conf.d/apps.conf` | Alpine BE apps vhost |
| `tmp/BE_alpine/var/www/localhost/htdocs/apps/...` | BE web tree |
Treat `tmp/` as **local mirrors** — sync/reload on the real host after edits.
---
## 7. FE nginx — two server names
Mounted copy: `tmp/FE_gentoo/etc/nginx/nginx.conf`.
| `server_name` | `location /` | androidcast crashes | Hub `/app/androidcast_project/` |
|---------------|--------------|---------------------|--------------------------------|
| **`apps.f0xx.org`** | → `http://artc0:80` | May → `https://artc0:443/.../crashes/` | Via **`location /`** → BE :80 (no extra block needed) |
| **`artc0.f0xx.org`** | → `http://artc0:80` | Legacy → **:8089** | Legacy → **:8089** — **remove** hub block; prefer apps.f0xx.org |
**Agent default:** fix **BE `listen 80`** first. Change FE only when user asks or to delete wrong **8089** hub overrides on `artc0.f0xx.org`.
---
## 8. Verify
```bash
# BE directly (simulates FE Host header)
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" -H "Host: apps.f0xx.org" \
"http://artc0.intra.raptor.org/app/androidcast_project/crashes/"
# Public
curl -sS -o /dev/null -w "%{http_code}\n" \
"https://apps.f0xx.org/app/androidcast_project/"
```
Expect **200** (or **302** → crashes only if you intentionally redirect root).
---
## 9. Backend app (crashes / tickets)
- PHP 8.1+ FPM on Alpine; MariaDB DB **`androidcast_crashes`**.
- Migration **`003_tickets.sql`** as **MySQL root** if UI says table missing (app user cannot DDL).
- `Database::requireTicketsTable()` must open PDO before `SHOW TABLES` (fixed in repo).
- Ingest smoke tickets: `php scripts/ingest_alpha_smoke_tickets.php --url=https://apps.f0xx.org/app/androidcast_project/crashes`
---
## 10. Rules for agents
1. **KISS** — prefer **one** BE change (`listen 80` fragment) over FE + BE + 8089 guesses.
2. **Never** point androidcast hub/crashes docs at **:8089** for this install.
3. **Do not** use `alias` to `index.html` as a file path (breaks trailing slash / CSS).
4. **Do not** commit `tmp/` sshfs trees, `*.log`, or workspace files unless user asks.
5. **Do not** commit to `master`/`next` directly — use `feature/*` → merge to `next` ([GIT_FLOW](GIT_FLOW.md)).
6. User may be unwell / slow to deploy — give **short** copy-paste steps, not long runbooks.
---
## 11. Changelog (infra doc)
| Date | Note |
|------|------|
| 2026-05-21 | Documented BE-only hub on port 80; 8089 = Janus only; FE `apps` vs `artc0` split |

90
docs/TICKETS_ROADMAP.md Normal file
View File

@@ -0,0 +1,90 @@
# Tickets, Gitea, and alpha smoke delegation
## Gitea (`/app/androidcast_project/git/`)
Public URL should return Gitea HTML (assets under `/app/androidcast_project/git/assets/…`).
**If clone/push fails while the page loads:**
1. **Clone URL** must include the subpath, e.g.
`https://apps.f0xx.org/app/androidcast_project/git/<owner>/<repo>.git`
2. **Gitea `app.ini`** on BE (`:3000`):
`ROOT_URL = https://apps.f0xx.org/app/androidcast_project/git/`
`DOMAIN = apps.f0xx.org`
3. **FE** `nginx.conf` ~983996: `proxy_pass http://artc0.intra.raptor.org:3000/;` only works if Gitea is configured for that subpath (your HTML manifest suggests it is).
4. **BE**: Gitea must listen on `10.7.16.128:3000` (or `0.0.0.0:3000`); no extra nginx required unless you proxy git through `:80` too.
Symptom checklist: 502 → Gitea down; broken CSS → `ROOT_URL` wrong; 401 on push → credentials / token; 404 on clone → remote path missing subpath.
---
## Alpha smoke tickets (typical DB id → block)
Ingest order from `tmp/alpha_smoke_round1.txt` (skips pure “pass” rows):
| id (typical) | Block | Theme | Delegation |
|--------------|-------|--------|------------|
| 1 | A3 | PIN/cast OK; **rotation + preview NOK** | App: sender preview / rotation crash |
| 2 | A4 | Video OK; **frame drops, low res, few TX losses** | App: UDP/encoder quality; monitor NDK only if native crashes appear |
| 3 | A5 | Audio works; **laggy sound** | App: AAC pipeline / jitter buffer |
| 4 | A6 | Notification resolution | Often lower priority (pass with notes) |
| 5 | A7 | Stop OK; **wrong lifecycle** (resume, notifications) | App: `CastForegroundService`, notification intents, task stack |
| 6 | A9 | Repeat soak; **lifecycle** again | Same as #5 |
| 7 | (manual) | **Delegation / process** | Console: assignees, workflow, comments (this doc) |
Confirm ids in UI (title contains `Alpha smoke A3` etc.).
---
## Console features (implementation)
| Feature | Status |
|---------|--------|
| Workflow state (`triage``in_progress``blocked` / `wont_fix``done`) | Migration `004` + UI |
| Assignees (0..n users, chip picker) | `assignees_json` + API |
| Comments (thread on ticket) | `ticket_comments` + API |
| Email notify reporter | Nice-to-have later |
| **Attachments** (files + links) | Migration `005` + ticket UI |
| **Google Suite** (OAuth, embed, Chat/Meet hooks) | Phase 2 — see below |
### Attachments (deploy)
```bash
mysql -u root -p androidcast_crashes < examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sql
chown -R www-data:www-data examples/crash_reporter/backend/storage
```
- **Links:** paste HTTPS URL; Google hosts auto-tagged (Docs, Sheets, Slides, Drive, Meet, Chat, Forms).
- **Files:** stored under `backend/storage/ticket_attachments/{ticket_id}/`, download via `/api/ticket_attachment.php?id=…`.
### Google Workspace — phase 2 (not implemented)
Requires Google Cloud project, OAuth consent, service account or user OAuth:
| Product | Integration idea |
|---------|------------------|
| Docs / Sheets / Slides | Picker API → attach link + optional embed iframe |
| Drive | File picker + shared-drive ACL |
| Meet | Attach recording link after call |
| Chat | Webhook or Chat API for ticket updates |
| Gmail | Optional notify assignees |
Until then, **paste share links** — the console labels them and opens in a new tab.
**Deploy migration (MariaDB, as root):**
```bash
mysql -u root -p androidcast_crashes < examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sql
```
---
## Agent / engineering queue (from your list)
1. **#1 (A3 NOK)** — Reproduce rotation + preview crash; separate from PIN path.
2. **#5 / #7 / #6 (lifecycle)** — Audit cold start, recents, notification tap, stop/receive; align with alpha A7/A9.
3. **#3 (laggy audio)** — Measure end-to-end latency sender → receiver; tune buffer / sync.
4. **#2 (TX losses)** — Log UDP gaps; recovery already OK — reduce loss if easy win.
5. **NDK** — Only if crash console shows native reports for these sessions.
Use ticket **comments** for findings; set **workflow** + **assignees** when you hand off.

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">

View File

@@ -5,6 +5,8 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
RECONNECT_KNOWN_DEVICES=${RECONNECT_KNOWN_DEVICES:-no}
# Hints for wireless debugging. Edit as needed.
## CONNECTIONS_LIST_HINTS[IP_WITH_UNDERLINES]="hint string"
declare -A CONNECTIONS_LIST_HINTS
@@ -173,7 +175,9 @@ if [[ ! -f "$APK" ]]; then
exit 1
fi
reconnect_known_devices
if [[ "${RECONNECT_KNOWN_DEVICES}" =~ "[yY]*" ]]; then
reconnect_known_devices
fi
mapfile -t DEVICES < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }')