From ef36f91b8255c63a544cdc3fcb25d21e3c15333c Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 26 May 2026 13:14:14 +0200 Subject: [PATCH] next/side sync --- AGENTS.md | 10 + README.md | 2 +- docs/CRASH_REPORTER.md | 3 +- docs/INFRA.md | 202 ++++++++++++ docs/TICKETS_ROADMAP.md | 90 ++++++ examples/app_hub/DEPLOY.md | 71 +++++ examples/app_hub/README.md | 11 +- .../app_hub/deploy/BE-apps-hub.conf.snippet | 15 + examples/crash_reporter/backend/README.md | 7 + .../backend/nginx.apps-port80.fragment | 47 +++ .../backend/nginx.apps.conf.fragment | 4 +- .../backend/public/api/ticket_attachment.php | 43 +++ .../backend/public/api/ticket_attachments.php | 109 +++++++ .../backend/public/api/ticket_comments.php | 75 +++++ .../backend/public/api/ticket_update.php | 5 +- .../backend/public/api/users.php | 9 + .../backend/public/assets/css/app.css | 40 +++ .../backend/public/assets/i18n/en.json | 17 +- .../backend/public/assets/i18n/ru.json | 18 +- .../backend/public/assets/js/app.js | 293 ++++++++++++++++++ .../crash_reporter/backend/public/index.php | 20 ++ .../backend/scripts/deploy-to-be-mount.sh | 14 + .../sql/migrations/004_ticket_workflow.sql | 18 ++ .../migrations/004_ticket_workflow.sqlite.sql | 13 + .../sql/migrations/005_ticket_attachments.sql | 20 ++ .../005_ticket_attachments.sqlite.sql | 16 + .../backend/src/TicketAttachmentProvider.php | 62 ++++ .../src/TicketAttachmentRepository.php | 207 +++++++++++++ .../backend/src/TicketCommentRepository.php | 61 ++++ .../backend/src/TicketRepository.php | 83 ++++- .../backend/src/TicketTagCatalog.php | 2 + .../backend/src/TicketWorkflow.php | 84 +++++ .../backend/src/UserRepository.php | 44 +++ .../backend/views/ticket_detail.php | 123 ++++++++ rebuild.sh | 6 +- 35 files changed, 1828 insertions(+), 16 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/INFRA.md create mode 100644 docs/TICKETS_ROADMAP.md create mode 100644 examples/app_hub/DEPLOY.md create mode 100644 examples/app_hub/deploy/BE-apps-hub.conf.snippet create mode 100644 examples/crash_reporter/backend/nginx.apps-port80.fragment create mode 100644 examples/crash_reporter/backend/public/api/ticket_attachment.php create mode 100644 examples/crash_reporter/backend/public/api/ticket_attachments.php create mode 100644 examples/crash_reporter/backend/public/api/ticket_comments.php create mode 100644 examples/crash_reporter/backend/public/api/users.php create mode 100755 examples/crash_reporter/backend/scripts/deploy-to-be-mount.sh create mode 100644 examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sql create mode 100644 examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sqlite.sql create mode 100644 examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sql create mode 100644 examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sqlite.sql create mode 100644 examples/crash_reporter/backend/src/TicketAttachmentProvider.php create mode 100644 examples/crash_reporter/backend/src/TicketAttachmentRepository.php create mode 100644 examples/crash_reporter/backend/src/TicketCommentRepository.php create mode 100644 examples/crash_reporter/backend/src/TicketWorkflow.php create mode 100644 examples/crash_reporter/backend/src/UserRepository.php diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4024a9e --- /dev/null +++ b/AGENTS.md @@ -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`**. diff --git a/README.md b/README.md index 685d702..38db4fd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/CRASH_REPORTER.md b/docs/CRASH_REPORTER.md index eceebfa..0160ce0 100644 --- a/docs/CRASH_REPORTER.md +++ b/docs/CRASH_REPORTER.md @@ -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`. diff --git a/docs/INFRA.md b/docs/INFRA.md new file mode 100644 index 0000000..7ff0239 --- /dev/null +++ b/docs/INFRA.md @@ -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 | diff --git a/docs/TICKETS_ROADMAP.md b/docs/TICKETS_ROADMAP.md new file mode 100644 index 0000000..4b67634 --- /dev/null +++ b/docs/TICKETS_ROADMAP.md @@ -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//.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` ~983–996: `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. diff --git a/examples/app_hub/DEPLOY.md b/examples/app_hub/DEPLOY.md new file mode 100644 index 0000000..7961597 --- /dev/null +++ b/examples/app_hub/DEPLOY.md @@ -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) diff --git a/examples/app_hub/README.md b/examples/app_hub/README.md index 3bd0452..5305eff 100644 --- a/examples/app_hub/README.md +++ b/examples/app_hub/README.md @@ -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`. diff --git a/examples/app_hub/deploy/BE-apps-hub.conf.snippet b/examples/app_hub/deploy/BE-apps-hub.conf.snippet new file mode 100644 index 0000000..91f219b --- /dev/null +++ b/examples/app_hub/deploy/BE-apps-hub.conf.snippet @@ -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/; +} diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md index 948fd3a..858edfa 100644 --- a/examples/crash_reporter/backend/README.md +++ b/examples/crash_reporter/backend/README.md @@ -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 | diff --git a/examples/crash_reporter/backend/nginx.apps-port80.fragment b/examples/crash_reporter/backend/nginx.apps-port80.fragment new file mode 100644 index 0000000..9b6f508 --- /dev/null +++ b/examples/crash_reporter/backend/nginx.apps-port80.fragment @@ -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; + } +} diff --git a/examples/crash_reporter/backend/nginx.apps.conf.fragment b/examples/crash_reporter/backend/nginx.apps.conf.fragment index ee80885..c2ee27f 100644 --- a/examples/crash_reporter/backend/nginx.apps.conf.fragment +++ b/examples/crash_reporter/backend/nginx.apps.conf.fragment @@ -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; } diff --git a/examples/crash_reporter/backend/public/api/ticket_attachment.php b/examples/crash_reporter/backend/public/api/ticket_attachment.php new file mode 100644 index 0000000..350779a --- /dev/null +++ b/examples/crash_reporter/backend/public/api/ticket_attachment.php @@ -0,0 +1,43 @@ + false, 'error' => 'unauthorized'], 401); +} + +try { + Database::requireTicketsTable(); +} catch (RuntimeException $e) { + json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503); +} + +$user = Auth::user(); +$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + +if ($method === 'GET') { + $ticketId = (int) ($_GET['ticket_id'] ?? 0); + if ($ticketId <= 0) { + json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400); + } + try { + json_out(['ok' => true, 'attachments' => TicketAttachmentRepository::listForTicket($ticketId)]); + } catch (Throwable $e) { + if (str_contains($e->getMessage(), 'ticket_attachments') || str_contains($e->getMessage(), 'no such table')) { + json_out(['ok' => false, 'error' => 'attachments_table_missing', 'hint' => 'Run sql/migrations/005_ticket_attachments.sql'], 503); + } + throw $e; + } +} + +if ($method === 'DELETE') { + $id = (int) ($_GET['id'] ?? 0); + if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); + } + try { + TicketAttachmentRepository::delete($id, (string) ($user['username'] ?? '')); + json_out(['ok' => true]); + } catch (RuntimeException $e) { + $code = $e->getMessage() === 'forbidden' ? 403 : 404; + json_out(['ok' => false, 'error' => $e->getMessage()], $code); + } catch (Throwable $e) { + if (str_contains($e->getMessage(), 'ticket_attachments')) { + json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503); + } + throw $e; + } +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method not allowed'], 405); +} + +$ticketId = (int) ($_POST['ticket_id'] ?? 0); +if ($ticketId <= 0) { + $raw = file_get_contents('php://input'); + $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null; + if (is_array($data)) { + $ticketId = (int) ($data['ticket_id'] ?? 0); + } +} + +if ($ticketId <= 0) { + json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400); +} + +$ticket = TicketRepository::getById($ticketId); +if (!$ticket) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} +// Any logged-in user who can view the ticket may add attachments (same as comments). + +try { + if (!empty($_FILES['file']['name'])) { + $att = TicketAttachmentRepository::addFile( + $ticketId, + (string) ($user['username'] ?? ''), + $_FILES['file'], + trim((string) ($_POST['title'] ?? '')) + ); + json_out(['ok' => true, 'attachment' => $att]); + } + $raw = file_get_contents('php://input'); + $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; + if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'file or url required'], 400); + } + $url = trim((string) ($data['url'] ?? $data['external_url'] ?? '')); + if ($url === '') { + json_out(['ok' => false, 'error' => 'url required'], 400); + } + $att = TicketAttachmentRepository::addLink( + $ticketId, + (string) ($user['username'] ?? ''), + $url, + trim((string) ($data['title'] ?? '')) + ); + json_out(['ok' => true, 'attachment' => $att]); +} catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 400); +} catch (Throwable $e) { + if (str_contains($e->getMessage(), 'ticket_attachments')) { + json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503); + } + error_log('ticket_attachments: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'save failed'], 500); +} diff --git a/examples/crash_reporter/backend/public/api/ticket_comments.php b/examples/crash_reporter/backend/public/api/ticket_comments.php new file mode 100644 index 0000000..e735708 --- /dev/null +++ b/examples/crash_reporter/backend/public/api/ticket_comments.php @@ -0,0 +1,75 @@ + false, 'error' => 'unauthorized'], 401); +} + +try { + Database::requireTicketsTable(); +} catch (RuntimeException $e) { + json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503); +} + +$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + +if ($method === 'GET') { + $ticketId = (int) ($_GET['ticket_id'] ?? 0); + if ($ticketId <= 0) { + json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400); + } + $ticket = TicketRepository::getById($ticketId); + if (!$ticket) { + json_out(['ok' => false, 'error' => 'not found'], 404); + } + try { + json_out(['ok' => true, 'comments' => TicketCommentRepository::listForTicket($ticketId)]); + } catch (Throwable $e) { + if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) { + json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503); + } + throw $e; + } +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method not allowed'], 405); +} + +$user = Auth::user(); +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} + +$ticketId = (int) ($data['ticket_id'] ?? 0); +if ($ticketId <= 0) { + json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400); +} + +$ticket = TicketRepository::getById($ticketId); +if (!$ticket) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} +if (empty($ticket['can_edit']) && !Auth::canEditTags()) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); +} + +try { + $comment = TicketCommentRepository::add( + $ticketId, + (string) ($user['username'] ?? ''), + (string) ($data['body'] ?? '') + ); + json_out(['ok' => true, 'comment' => $comment]); +} catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 400); +} catch (Throwable $e) { + if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) { + json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503); + } + error_log('ticket_comments: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'save failed'], 500); +} diff --git a/examples/crash_reporter/backend/public/api/ticket_update.php b/examples/crash_reporter/backend/public/api/ticket_update.php index c2343e7..fe34661 100644 --- a/examples/crash_reporter/backend/public/api/ticket_update.php +++ b/examples/crash_reporter/backend/public/api/ticket_update.php @@ -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); diff --git a/examples/crash_reporter/backend/public/api/users.php b/examples/crash_reporter/backend/public/api/users.php new file mode 100644 index 0000000..9bea9e0 --- /dev/null +++ b/examples/crash_reporter/backend/public/api/users.php @@ -0,0 +1,9 @@ + false, 'error' => 'unauthorized'], 401); +} + +json_out(['ok' => true, 'users' => UserRepository::listForAssignees()]); diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index cd16996..71bbc0a 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -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) { diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json index cf70dd4..711e13b 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/en.json +++ b/examples/crash_reporter/backend/public/assets/i18n/en.json @@ -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" } diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json index 610f9bd..f2719fe 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/ru.json +++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json @@ -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": "Файл" } diff --git a/examples/crash_reporter/backend/public/assets/js/app.js b/examples/crash_reporter/backend/public/assets/js/app.js index 4688cbd..cee52b2 100644 --- a/examples/crash_reporter/backend/public/assets/js/app.js +++ b/examples/crash_reporter/backend/public/assets/js/app.js @@ -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 = ''; + return; + } + assigneeEditor.innerHTML = assignees + .map(function (a, idx) { + const role = a.role ? ' (' + escapeHtml(a.role) + ')' : ''; + return ( + '' + + escapeHtml(a.username) + + role + + ' ' + ); + }) + .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 = + '' + + escapeHtml(att.title || att.external_url) + + ''; + } else if (att.download_url) { + const size = + att.size_bytes > 0 + ? ' (' + (att.size_bytes / 1024).toFixed(1) + ' KB)' + : ''; + main = + '' + + escapeHtml(att.title || att.original_name || 'file') + + '' + + size; + } else { + main = '' + escapeHtml(att.title || '') + ''; + } + li.innerHTML = + '' + + label + + ' ' + + main + + ' ' + + escapeHtml(att.created_by || '') + + '' + + ' '; + 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 = + '
' + + escapeHtml(c.author_username) + + ' ' + + escapeHtml(when) + + '
';
+            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);
diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php
index 2a8de9e..6da630b 100644
--- a/examples/crash_reporter/backend/public/index.php
+++ b/examples/crash_reporter/backend/public/index.php
@@ -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');
diff --git a/examples/crash_reporter/backend/scripts/deploy-to-be-mount.sh b/examples/crash_reporter/backend/scripts/deploy-to-be-mount.sh
new file mode 100755
index 0000000..1888d3c
--- /dev/null
+++ b/examples/crash_reporter/backend/scripts/deploy-to-be-mount.sh
@@ -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)."
diff --git a/examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sql b/examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sql
new file mode 100644
index 0000000..611c090
--- /dev/null
+++ b/examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sql
@@ -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;
diff --git a/examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sqlite.sql b/examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sqlite.sql
new file mode 100644
index 0000000..5c7a66a
--- /dev/null
+++ b/examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sqlite.sql
@@ -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);
diff --git a/examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sql b/examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sql
new file mode 100644
index 0000000..6a824a0
--- /dev/null
+++ b/examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sql
@@ -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;
diff --git a/examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sqlite.sql b/examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sqlite.sql
new file mode 100644
index 0000000..f4d8b6b
--- /dev/null
+++ b/examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sqlite.sql
@@ -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);
diff --git a/examples/crash_reporter/backend/src/TicketAttachmentProvider.php b/examples/crash_reporter/backend/src/TicketAttachmentProvider.php
new file mode 100644
index 0000000..c40e795
--- /dev/null
+++ b/examples/crash_reporter/backend/src/TicketAttachmentProvider.php
@@ -0,0 +1,62 @@
+ */
+    public static function labels(): array {
+        return [
+            self::GENERIC => ['id' => self::GENERIC, 'label' => 'Link'],
+            self::GOOGLE_DOCS => ['id' => self::GOOGLE_DOCS, 'label' => 'Google Docs'],
+            self::GOOGLE_SHEETS => ['id' => self::GOOGLE_SHEETS, 'label' => 'Google Sheets'],
+            self::GOOGLE_SLIDES => ['id' => self::GOOGLE_SLIDES, 'label' => 'Google Slides'],
+            self::GOOGLE_DRIVE => ['id' => self::GOOGLE_DRIVE, 'label' => 'Google Drive'],
+            self::GOOGLE_MEET => ['id' => self::GOOGLE_MEET, 'label' => 'Google Meet'],
+            self::GOOGLE_CHAT => ['id' => self::GOOGLE_CHAT, 'label' => 'Google Chat'],
+            self::GOOGLE_FORMS => ['id' => self::GOOGLE_FORMS, 'label' => 'Google Forms'],
+        ];
+    }
+
+    public static function labelFor(string $provider): string {
+        $labels = self::labels();
+        return $labels[$provider]['label'] ?? 'Link';
+    }
+
+    public static function detectFromUrl(string $url): string {
+        $host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
+        if ($host === '') {
+            return self::GENERIC;
+        }
+        if (str_contains($host, 'docs.google.com')) {
+            return self::GOOGLE_DOCS;
+        }
+        if (str_contains($host, 'sheets.google.com')) {
+            return self::GOOGLE_SHEETS;
+        }
+        if (str_contains($host, 'slides.google.com')) {
+            return self::GOOGLE_SLIDES;
+        }
+        if (str_contains($host, 'drive.google.com')) {
+            return self::GOOGLE_DRIVE;
+        }
+        if (str_contains($host, 'meet.google.com')) {
+            return self::GOOGLE_MEET;
+        }
+        if (str_contains($host, 'chat.google.com')) {
+            return self::GOOGLE_CHAT;
+        }
+        if (str_contains($host, 'forms.gle') || str_contains($host, 'forms.google.com')) {
+            return self::GOOGLE_FORMS;
+        }
+        return self::GENERIC;
+    }
+}
diff --git a/examples/crash_reporter/backend/src/TicketAttachmentRepository.php b/examples/crash_reporter/backend/src/TicketAttachmentRepository.php
new file mode 100644
index 0000000..2a4b71c
--- /dev/null
+++ b/examples/crash_reporter/backend/src/TicketAttachmentRepository.php
@@ -0,0 +1,207 @@
+ */
+    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 $row */
+    private static function mapRow(array $row, string $basePath): array {
+        $id = (int) $row['id'];
+        $kind = (string) $row['kind'];
+        $provider = (string) ($row['provider'] ?? TicketAttachmentProvider::GENERIC);
+        $item = [
+            'id' => $id,
+            'ticket_id' => (int) $row['ticket_id'],
+            'kind' => $kind,
+            'title' => (string) ($row['title'] ?? ''),
+            'external_url' => (string) ($row['external_url'] ?? ''),
+            'provider' => $provider,
+            'provider_label' => TicketAttachmentProvider::labelFor($provider),
+            'original_name' => (string) ($row['original_name'] ?? ''),
+            'mime_type' => (string) ($row['mime_type'] ?? ''),
+            'size_bytes' => (int) ($row['size_bytes'] ?? 0),
+            'created_by' => (string) ($row['created_by'] ?? ''),
+            'created_at_ms' => (int) ($row['created_at_ms'] ?? 0),
+            'download_url' => $kind === 'file'
+                ? $basePath . '/api/ticket_attachment.php?id=' . $id
+                : null,
+        ];
+        return $item;
+    }
+}
diff --git a/examples/crash_reporter/backend/src/TicketCommentRepository.php b/examples/crash_reporter/backend/src/TicketCommentRepository.php
new file mode 100644
index 0000000..f420c68
--- /dev/null
+++ b/examples/crash_reporter/backend/src/TicketCommentRepository.php
@@ -0,0 +1,61 @@
+prepare(
+            'SELECT id, ticket_id, author_username, body, created_at_ms, updated_at_ms
+             FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at_ms ASC, id ASC'
+        );
+        $stmt->execute([$ticketId]);
+        $out = [];
+        foreach ($stmt->fetchAll() as $row) {
+            $out[] = [
+                'id' => (int) $row['id'],
+                'ticket_id' => (int) $row['ticket_id'],
+                'author_username' => (string) $row['author_username'],
+                'body' => (string) $row['body'],
+                'created_at_ms' => (int) $row['created_at_ms'],
+                'updated_at_ms' => (int) $row['updated_at_ms'],
+            ];
+        }
+        return $out;
+    }
+
+    public static function add(int $ticketId, string $author, string $body): array {
+        $ticket = TicketRepository::getById($ticketId);
+        if (!$ticket) {
+            throw new RuntimeException('not found');
+        }
+        $body = trim($body);
+        if ($body === '') {
+            throw new InvalidArgumentException('comment body required');
+        }
+        $author = mb_substr(trim($author), 0, 64);
+        if ($author === '') {
+            throw new InvalidArgumentException('author required');
+        }
+        $now = (int) round(microtime(true) * 1000);
+        $pdo = Database::pdo();
+        $stmt = $pdo->prepare(
+            'INSERT INTO ticket_comments (ticket_id, author_username, body, created_at_ms, updated_at_ms)
+             VALUES (?, ?, ?, ?, ?)'
+        );
+        $stmt->execute([$ticketId, $author, $body, $now, $now]);
+        $id = (int) $pdo->lastInsertId();
+        $pdo->prepare('UPDATE tickets SET updated_at_ms = ? WHERE id = ?')->execute([$now, $ticketId]);
+        return [
+            'id' => $id,
+            'ticket_id' => $ticketId,
+            'author_username' => $author,
+            'body' => $body,
+            'created_at_ms' => $now,
+            'updated_at_ms' => $now,
+        ];
+    }
+}
diff --git a/examples/crash_reporter/backend/src/TicketRepository.php b/examples/crash_reporter/backend/src/TicketRepository.php
index b9ce728..5e3fb0d 100644
--- a/examples/crash_reporter/backend/src/TicketRepository.php
+++ b/examples/crash_reporter/backend/src/TicketRepository.php
@@ -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 */
+    public static function parseAssigneesJson(string $json): array {
+        $data = json_decode($json, true);
+        if (!is_array($data)) {
+            return [];
+        }
+        return self::normalizeAssignees($data);
+    }
+
+    /**
+     * @param mixed $raw
+     * @return list
+     */
+    public static function normalizeAssignees($raw): array {
+        if (!is_array($raw)) {
+            return [];
+        }
+        $out = [];
+        $seen = [];
+        foreach ($raw as $item) {
+            if (is_string($item)) {
+                $item = ['username' => $item, 'role' => ''];
+            }
+            if (!is_array($item)) {
+                continue;
+            }
+            $user = trim((string) ($item['username'] ?? ''));
+            if ($user === '') {
+                continue;
+            }
+            $key = strtolower($user);
+            if (isset($seen[$key])) {
+                continue;
+            }
+            $seen[$key] = true;
+            $out[] = [
+                'username' => mb_substr($user, 0, 64),
+                'role' => mb_substr(trim((string) ($item['role'] ?? '')), 0, 32),
+            ];
+        }
+        return $out;
+    }
 }
 
 function ticket_format_os_version(array $device): string {
diff --git a/examples/crash_reporter/backend/src/TicketTagCatalog.php b/examples/crash_reporter/backend/src/TicketTagCatalog.php
index 9e86143..a71d5db 100644
--- a/examples/crash_reporter/backend/src/TicketTagCatalog.php
+++ b/examples/crash_reporter/backend/src/TicketTagCatalog.php
@@ -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'],
diff --git a/examples/crash_reporter/backend/src/TicketWorkflow.php b/examples/crash_reporter/backend/src/TicketWorkflow.php
new file mode 100644
index 0000000..297e3d1
--- /dev/null
+++ b/examples/crash_reporter/backend/src/TicketWorkflow.php
@@ -0,0 +1,84 @@
+ */
+    public static function all(): array {
+        return [
+            self::TRIAGE,
+            self::IN_PROGRESS,
+            self::BLOCKED,
+            self::WONT_FIX,
+            self::DONE,
+            self::CANCELLED,
+        ];
+    }
+
+    public static function normalize(string $state): string {
+        $s = strtolower(trim($state));
+        return in_array($s, self::all(), true) ? $s : self::TRIAGE;
+    }
+
+    /** @return array{id:string,label:string,bg:string} */
+    public static function labelDef(string $state): array {
+        $map = [
+            self::TRIAGE => ['id' => self::TRIAGE, 'label' => 'triage', 'bg' => '#64748b'],
+            self::IN_PROGRESS => ['id' => self::IN_PROGRESS, 'label' => 'in progress', 'bg' => '#3b82f6'],
+            self::BLOCKED => ['id' => self::BLOCKED, 'label' => 'blocked', 'bg' => '#b45309'],
+            self::WONT_FIX => ['id' => self::WONT_FIX, 'label' => "won't fix", 'bg' => '#6b7280'],
+            self::DONE => ['id' => self::DONE, 'label' => 'done', 'bg' => '#047857'],
+            self::CANCELLED => ['id' => self::CANCELLED, 'label' => 'cancelled', 'bg' => '#b91c1c'],
+        ];
+        return $map[self::normalize($state)] ?? $map[self::TRIAGE];
+    }
+
+    /** Map workflow to legacy status tag id for tag bar sync. */
+    public static function statusTagId(string $state): string {
+        return match (self::normalize($state)) {
+            self::IN_PROGRESS => 'in-progress',
+            self::BLOCKED => 'blocked',
+            self::WONT_FIX => 'wont-fix',
+            self::DONE => 'closed',
+            self::CANCELLED => 'rejected',
+            default => 'open',
+        };
+    }
+
+    /**
+     * Replace workflow/status tags; keep meta + custom tags.
+     *
+     * @param list $tags
+     * @return list
+     */
+    public static function mergeTagsForState(array $tags, string $state): array {
+        $keep = [];
+        $statusIds = array_merge(TicketTagCatalog::statusIds(), TicketWorkflow::all());
+        foreach (normalize_report_tags($tags) as $t) {
+            if ($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);
+    }
+}
diff --git a/examples/crash_reporter/backend/src/UserRepository.php b/examples/crash_reporter/backend/src/UserRepository.php
new file mode 100644
index 0000000..d72d808
--- /dev/null
+++ b/examples/crash_reporter/backend/src/UserRepository.php
@@ -0,0 +1,44 @@
+ */
+    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 $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;
+    }
+}
diff --git a/examples/crash_reporter/backend/views/ticket_detail.php b/examples/crash_reporter/backend/views/ticket_detail.php
index 8fb2987..cee4bb4 100644
--- a/examples/crash_reporter/backend/views/ticket_detail.php
+++ b/examples/crash_reporter/backend/views/ticket_detail.php
@@ -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 .= '';
@@ -106,6 +111,26 @@ for ($i = 1; $i <= 5; $i++) {
       
     
+    
+    
+ Assignees +
+
+ + +
+
+ + + + +
+

Upload file

+

Max 16 MiB — images, logs, pdf, zip, …

+ + + + +
+ + + + +
+

Comments

+
    + +
  • +
    + + +
    +
    +
  • + +
+ +
+ + + +
+ +
+
diff --git a/rebuild.sh b/rebuild.sh index c142dce..dd83854 100755 --- a/rebuild.sh +++ b/rebuild.sh @@ -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 }')