diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md
index 8684ac5..ea08762 100644
--- a/examples/crash_reporter/backend/README.md
+++ b/examples/crash_reporter/backend/README.md
@@ -135,6 +135,48 @@ See `sample_crash_java.json`. Key fields:
Backend stores full JSON in `reports.payload_json` for faithful replay in the UI.
+## Tickets (tasks / QA)
+
+Web UI: `/?view=tickets` — list columns **Issue**, **Opened**, **App/OS**, **Rating**, **Tags**.
+Detail: `/?view=ticket&id=N` — same layout family as crash reports (tags block, device/app/timing cards, collapsible raw JSON). Editable when you are **owner** or company **admin+**: caption, summary, body, owner, verified by, rating, tags.
+
+**Upload API** (no session; same as crashes):
+
+```http
+POST /api/ticket_upload.php
+Content-Type: application/json
+```
+
+```json
+{
+ "schema_version": 1,
+ "ingest_kind": "ticket",
+ "ticket_id": "unique-id",
+ "title": "Short caption",
+ "brief": "One-line summary for the list",
+ "body": "Full description",
+ "opened_at_epoch_ms": 1710000000000,
+ "rating": 3,
+ "owner": "admin",
+ "tags": [
+ {"id": "ticket", "label": "ticket", "bg": "#6366f1"},
+ {"id": "open", "label": "open", "bg": "#22c55e"}
+ ],
+ "device": { "model": "BL6000 Pro", "sdk_int": 29, "release": "10" },
+ "app": { "package": "com.foxx.androidcast", "version_name": "0.1.0" }
+}
+```
+
+Tags must include **`ticket`** plus at least one status tag (`open`, `in-progress`, `closed`, `rejected`, `confirmed`, `postponed`, `urgent`).
+
+**Ingest alpha smoke results** from repo `tmp/alpha_smoke_round1.txt`:
+
+```bash
+php scripts/ingest_alpha_smoke_tickets.php --url=https://apps.f0xx.org/app/androidcast_project/crashes
+```
+
+MariaDB: `mysql … < sql/migrations/003_tickets.sql` (or auto-create on first request via SQLite dev).
+
## Default accounts
| User | Password | Role |
diff --git a/examples/crash_reporter/backend/public/api/ticket_tags.php b/examples/crash_reporter/backend/public/api/ticket_tags.php
new file mode 100644
index 0000000..25709f4
--- /dev/null
+++ b/examples/crash_reporter/backend/public/api/ticket_tags.php
@@ -0,0 +1,69 @@
+ false, 'error' => 'unauthorized'], 401);
+}
+
+$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
+$id = (int) ($_GET['id'] ?? 0);
+
+if ($method === 'GET') {
+ if ($id <= 0) {
+ json_out(['ok' => false, 'error' => 'invalid id'], 400);
+ }
+ $ticket = TicketRepository::getById($id);
+ if (!$ticket) {
+ json_out(['ok' => false, 'error' => 'not found'], 404);
+ }
+ json_out([
+ 'ok' => true,
+ 'id' => $id,
+ 'tags' => $ticket['tags'] ?? [],
+ 'presets' => TicketRepository::listTagPresets(),
+ 'can_edit' => !empty($ticket['can_edit']),
+ ]);
+}
+
+if ($method !== 'PUT' && $method !== 'POST') {
+ json_out(['ok' => false, 'error' => 'method not allowed'], 405);
+}
+
+$raw = file_get_contents('php://input');
+$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
+if (!is_array($data)) {
+ json_out(['ok' => false, 'error' => 'invalid json'], 400);
+}
+$id = (int) ($data['id'] ?? $id);
+if ($id <= 0) {
+ json_out(['ok' => false, 'error' => 'invalid id'], 400);
+}
+
+$ticket = TicketRepository::getById($id);
+if (!$ticket) {
+ json_out(['ok' => false, 'error' => 'not found'], 404);
+}
+if (empty($ticket['can_edit'])) {
+ json_out(['ok' => false, 'error' => 'forbidden'], 403);
+}
+
+$tagsIn = $data['tags'] ?? [];
+if (!is_array($tagsIn)) {
+ json_out(['ok' => false, 'error' => 'tags must be array'], 400);
+}
+
+try {
+ TicketRepository::setCustomTags($id, $tagsIn);
+ $updated = TicketRepository::getById($id);
+ json_out([
+ 'ok' => true,
+ 'id' => $id,
+ 'tags' => $updated['tags'] ?? [],
+ ]);
+} catch (InvalidArgumentException $e) {
+ json_out(['ok' => false, 'error' => $e->getMessage()], 400);
+} catch (Throwable $e) {
+ error_log('ticket_tags: ' . $e->getMessage());
+ json_out(['ok' => false, 'error' => 'save failed'], 500);
+}
diff --git a/examples/crash_reporter/backend/public/api/ticket_update.php b/examples/crash_reporter/backend/public/api/ticket_update.php
new file mode 100644
index 0000000..314e7e7
--- /dev/null
+++ b/examples/crash_reporter/backend/public/api/ticket_update.php
@@ -0,0 +1,47 @@
+ false, 'error' => 'unauthorized'], 401);
+}
+
+if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'PUT' && strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
+ json_out(['ok' => false, 'error' => 'method not allowed'], 405);
+}
+
+$raw = file_get_contents('php://input');
+$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
+if (!is_array($data)) {
+ json_out(['ok' => false, 'error' => 'invalid json'], 400);
+}
+$id = (int) ($data['id'] ?? 0);
+if ($id <= 0) {
+ json_out(['ok' => false, 'error' => 'invalid id'], 400);
+}
+
+$ticket = TicketRepository::getById($id);
+if (!$ticket) {
+ json_out(['ok' => false, 'error' => 'not found'], 404);
+}
+if (empty($ticket['can_edit'])) {
+ json_out(['ok' => false, 'error' => 'forbidden'], 403);
+}
+
+$fields = [];
+foreach (['title', 'brief', 'body', 'rating', 'owner_username', 'verified_by_username'] as $key) {
+ if (array_key_exists($key, $data)) {
+ $fields[$key] = $data[$key];
+ }
+}
+
+try {
+ TicketRepository::update($id, $fields);
+ $updated = TicketRepository::getById($id);
+ json_out(['ok' => true, 'ticket' => $updated]);
+} catch (InvalidArgumentException $e) {
+ json_out(['ok' => false, 'error' => $e->getMessage()], 400);
+} catch (Throwable $e) {
+ error_log('ticket_update: ' . $e->getMessage());
+ json_out(['ok' => false, 'error' => 'save failed'], 500);
+}
diff --git a/examples/crash_reporter/backend/public/api/ticket_upload.php b/examples/crash_reporter/backend/public/api/ticket_upload.php
new file mode 100644
index 0000000..67f1a40
--- /dev/null
+++ b/examples/crash_reporter/backend/public/api/ticket_upload.php
@@ -0,0 +1,42 @@
+ false, 'error' => 'empty body'], 400);
+}
+$data = json_decode($raw, true);
+if (!is_array($data)) {
+ json_out(['ok' => false, 'error' => 'invalid json'], 400);
+}
+if ((int) ($data['schema_version'] ?? 0) !== 1) {
+ json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
+}
+if (($data['ingest_kind'] ?? 'ticket') !== 'ticket') {
+ json_out(['ok' => false, 'error' => 'ingest_kind must be ticket'], 400);
+}
+try {
+ TicketRepository::insert($data);
+ json_out(['ok' => true, 'ticket_id' => $data['ticket_id'] ?? null]);
+} catch (InvalidArgumentException $e) {
+ json_out(['ok' => false, 'error' => $e->getMessage()], 400);
+} catch (PDOException $e) {
+ $msg = $e->getMessage();
+ if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) {
+ json_out(['ok' => false, 'error' => 'duplicate ticket_id'], 409);
+ }
+ error_log('ticket upload PDO: ' . $msg);
+ $out = ['ok' => false, 'error' => 'store failed'];
+ if (cfg('debug')) {
+ $out['hint'] = $msg;
+ }
+ json_out($out, 500);
+} catch (Throwable $e) {
+ error_log('ticket upload: ' . $e->getMessage());
+ $out = ['ok' => false, 'error' => 'store failed'];
+ if (cfg('debug')) {
+ $out['hint'] = $e->getMessage();
+ }
+ json_out($out, 500);
+}
diff --git a/examples/crash_reporter/backend/public/api/tickets.php b/examples/crash_reporter/backend/public/api/tickets.php
new file mode 100644
index 0000000..ff6207f
--- /dev/null
+++ b/examples/crash_reporter/backend/public/api/tickets.php
@@ -0,0 +1,28 @@
+ false, 'error' => 'unauthorized'], 401);
+}
+
+$page = max(1, (int) ($_GET['page'] ?? 1));
+$perPage = (int) ($_GET['per_page'] ?? 50);
+if (!in_array($perPage, [25, 50, 75, 100, 200], true)) {
+ $perPage = 50;
+}
+$sort = (string) ($_GET['sort'] ?? 'opened_at_ms');
+$dir = (string) ($_GET['dir'] ?? 'desc');
+$tag = trim((string) ($_GET['tag'] ?? ''));
+
+try {
+ $data = TicketRepository::listPage($page, $perPage, $sort, $dir, $tag);
+ json_out(['ok' => true] + $data);
+} catch (Throwable $e) {
+ error_log('tickets api: ' . $e->getMessage());
+ $out = ['ok' => false, 'error' => 'list failed'];
+ if (cfg('debug')) {
+ $out['hint'] = $e->getMessage();
+ }
+ json_out($out, 500);
+}
diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css
index 8bbeebc..546b409 100644
--- a/examples/crash_reporter/backend/public/assets/css/app.css
+++ b/examples/crash_reporter/backend/public/assets/css/app.css
@@ -196,6 +196,46 @@ a:hover { text-decoration: underline; }
}
.nav-icon--reports::before { top: 5px; }
.nav-icon--reports::after { top: 10px; width: 70%; }
+.nav-icon--tickets {
+ width: 16px;
+ height: 16px;
+ border: 2px solid currentColor;
+ border-radius: 2px;
+}
+.nav-icon--tickets::before {
+ content: "";
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ width: 6px;
+ height: 2px;
+ background: currentColor;
+ box-shadow: 0 4px 0 currentColor;
+}
+.ticket-col-issue { min-width: 200px; max-width: 420px; }
+.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; }
+.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) {
+ .ticket-edit-form { grid-template-columns: 1fr 280px; }
+ .ticket-edit-form .card--wide { grid-column: 1; grid-row: 1 / span 2; }
+}
+.ticket-field { display: block; margin-bottom: 12px; }
+.ticket-field span { display: block; font-size: 0.85em; color: var(--muted); margin-bottom: 4px; }
+.ticket-field input,
+.ticket-field textarea,
+.ticket-field select {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 8px 10px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text);
+}
+.ticket-body { white-space: pre-wrap; word-break: break-word; }
/* User */
.nav-icon--user {
width: 18px;
diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json
index a86b333..cf70dd4 100644
--- a/examples/crash_reporter/backend/public/assets/i18n/en.json
+++ b/examples/crash_reporter/backend/public/assets/i18n/en.json
@@ -2,6 +2,7 @@
"app_name": "Android Cast Crashes",
"nav.home": "Home",
"nav.reports": "Reports",
+ "nav.tickets": "Tickets",
"nav.logout": "Logout",
"nav.toggle": "Navigation",
"theme.label": "Theme",
@@ -14,6 +15,7 @@
"home.intro": "Anonymous Android Cast crash ingest is active. Use Reports to analyze grouped fingerprints.",
"home.upload_api": "Upload API:",
"home.schema": "Schema:",
+ "home.ticket_upload_api": "Ticket upload API:",
"reports.title": "Crash reports",
"reports.by_time": "By time",
"reports.grouped": "Grouped",
@@ -112,5 +114,30 @@
"login.register": "Register",
"login.register_soon": "(coming soon)",
"login.error": "Invalid credentials",
- "footer.copyright": "(c) Anton Afanaasyeu, {year}"
+ "footer.copyright": "(c) Anton Afanaasyeu, {year}",
+ "tickets.title": "Tickets",
+ "tickets.empty": "No tickets yet.",
+ "tickets.col_issue": "Issue",
+ "tickets.col_opened": "Opened",
+ "tickets.col_env": "App / OS",
+ "tickets.filter_tag": "Status tag",
+ "tickets.filter_all": "All",
+ "ticket.back": "Back to tickets",
+ "ticket.detail_title": "Ticket",
+ "ticket.id_label": "ID",
+ "ticket.tag_hint": "Must include ticket and a status tag (open, in progress, closed, …).",
+ "ticket.tag_readonly": "Only the owner or an admin can edit tags.",
+ "ticket.issue": "Issue",
+ "ticket.caption": "Caption",
+ "ticket.summary": "Summary",
+ "ticket.description": "Description",
+ "ticket.workflow": "Workflow",
+ "ticket.owner": "Owner",
+ "ticket.verified_by": "Verified by",
+ "ticket.timing": "Timing",
+ "ticket.opened": "Opened",
+ "ticket.created": "Created",
+ "ticket.updated": "Updated",
+ "ticket.save": "Save ticket",
+ "ticket.source": "Source / context"
}
diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json
index 6afc935..610f9bd 100644
--- a/examples/crash_reporter/backend/public/assets/i18n/ru.json
+++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json
@@ -2,6 +2,10 @@
"app_name": "Сбои Android Cast",
"nav.home": "Главная",
"nav.reports": "Отчёты",
+ "nav.tickets": "Задачи",
+ "tickets.title": "Задачи",
+ "tickets.empty": "Задач пока нет.",
+ "ticket.detail_title": "Задача",
"nav.logout": "Выход",
"nav.toggle": "Навигация",
"theme.label": "Тема",
diff --git a/examples/crash_reporter/backend/public/assets/js/app.js b/examples/crash_reporter/backend/public/assets/js/app.js
index 34568e7..4688cbd 100644
--- a/examples/crash_reporter/backend/public/assets/js/app.js
+++ b/examples/crash_reporter/backend/public/assets/js/app.js
@@ -1159,8 +1159,18 @@
return html + '';
}
+ function tagsApiUrl(entityType) {
+ return basePath() + (entityType === 'ticket' ? '/api/ticket_tags.php' : '/api/report_tags.php');
+ }
+
function createTagEditor(cfg) {
- const state = { tags: [...(cfg.initialTags || [])], presets: [], reportId: cfg.reportId };
+ const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report';
+ const state = {
+ tags: [...(cfg.initialTags || [])],
+ presets: [],
+ entityId: cfg.entityId ?? cfg.reportId ?? cfg.ticketId ?? 0,
+ entityType,
+ };
const els = cfg.elements;
function renderList() {
@@ -1276,7 +1286,7 @@
els.saveBtn.addEventListener('click', () => {
setStatus(t('tag.saving'), true);
const xhr = new XMLHttpRequest();
- xhr.open('POST', basePath() + '/api/report_tags.php', true);
+ xhr.open('POST', tagsApiUrl(state.entityType), true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
@@ -1293,18 +1303,18 @@
state.tags = data.tags || [];
renderAll();
setStatus('Saved', true);
- if (cfg.onSaved) cfg.onSaved(state.tags, state.reportId);
+ if (cfg.onSaved) cfg.onSaved(state.tags, state.entityId);
};
xhr.onerror = function () {
setStatus(t('reports.network_error'), false);
};
- xhr.send(JSON.stringify({ id: state.reportId, tags: state.tags }));
+ xhr.send(JSON.stringify({ id: state.entityId, tags: state.tags }));
});
}
function loadFromApi() {
const xhr = new XMLHttpRequest();
- xhr.open('GET', basePath() + '/api/report_tags.php?id=' + state.reportId, true);
+ xhr.open('GET', tagsApiUrl(state.entityType) + '?id=' + state.entityId, true);
xhr.onload = function () {
let data;
try {
@@ -1341,15 +1351,19 @@
let tagModalEditor = null;
- function openTagModal(reportId) {
+ function openTagModal(entityId, entityType) {
const modal = document.getElementById('tag-modal');
const editorEl = document.getElementById('tag-editor-modal');
if (!modal || !editorEl || !canTagEdit()) return;
- editorEl.setAttribute('data-report-id', String(reportId));
+ const et = entityType === 'ticket' ? 'ticket' : 'report';
+ editorEl.setAttribute('data-report-id', et === 'report' ? String(entityId) : '0');
+ editorEl.setAttribute('data-ticket-id', et === 'ticket' ? String(entityId) : '0');
+ editorEl.setAttribute('data-entity-type', et);
modal.hidden = false;
modal.setAttribute('aria-hidden', 'false');
tagModalEditor = createTagEditor({
- reportId: Number(reportId),
+ entityId: Number(entityId),
+ entityType: et,
initialTags: [],
fetchPresets: true,
elements: {
@@ -1363,9 +1377,11 @@
},
onSaved() {
if (typeof reportsReloadRef === 'function') reportsReloadRef();
+ if (typeof window.ticketsReloadRef === 'function') window.ticketsReloadRef();
},
});
}
+ window.openTagModal = openTagModal;
function closeTagModal() {
const modal = document.getElementById('tag-modal');
@@ -1388,7 +1404,9 @@
function initTagEditorReadOnly() {
const preview = document.getElementById('tag-editor-preview');
- const dataEl = document.getElementById('report-custom-tags-data');
+ const dataEl =
+ document.getElementById('ticket-custom-tags-data') ||
+ document.getElementById('report-custom-tags-data');
if (!preview || !dataEl || canTagEdit()) return;
try {
const tags = JSON.parse(dataEl.textContent || '[]');
@@ -1403,20 +1421,27 @@
function initTagEditorDetail() {
const root = document.getElementById('tag-editor');
if (!root) return;
- if (!canTagEdit()) {
+ const entityType = root.getAttribute('data-entity-type') === 'ticket' ? 'ticket' : 'report';
+ const entityId = Number(
+ root.getAttribute(entityType === 'ticket' ? 'data-ticket-id' : 'data-report-id')
+ );
+ const canEdit = entityType === 'ticket' ? !!root.querySelector('#tag-editor-list') : canTagEdit();
+ if (!canEdit) {
initTagEditorReadOnly();
return;
}
- const reportId = Number(root.getAttribute('data-report-id'));
let initial = [];
- const dataEl = document.getElementById('report-custom-tags-data');
+ const dataEl =
+ document.getElementById('ticket-custom-tags-data') ||
+ document.getElementById('report-custom-tags-data');
if (dataEl) {
try {
initial = JSON.parse(dataEl.textContent || '[]');
} catch { /* ignore */ }
}
createTagEditor({
- reportId,
+ entityId,
+ entityType,
initialTags: initial,
fetchPresets: true,
elements: {
@@ -1431,6 +1456,63 @@
});
}
+ function initTicketDetail() {
+ const id = document.body.getAttribute('data-ticket-id');
+ if (!id) return;
+ const tree = document.getElementById('detail-tree');
+ if (tree) bindDetailTree(tree);
+ const form = document.getElementById('ticket-edit-form');
+ if (!form) return;
+ const statusEl = document.getElementById('ticket-save-status');
+ const ratingSel = form.querySelector('select[name="rating"]');
+ const starsPreview = form.querySelector('.ticket-stars-preview .star-rating');
+ if (ratingSel && starsPreview) {
+ ratingSel.addEventListener('change', () => {
+ const n = Math.max(0, Math.min(5, Number(ratingSel.value) || 0));
+ let html = '';
+ for (let i = 1; i <= 5; i++) {
+ html += '';
+ }
+ starsPreview.innerHTML = html;
+ });
+ }
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ if (statusEl) statusEl.textContent = 'Saving…';
+ const fd = new FormData(form);
+ const body = {
+ id: Number(form.getAttribute('data-ticket-id')),
+ title: fd.get('title'),
+ brief: fd.get('brief'),
+ body: fd.get('body'),
+ owner_username: fd.get('owner_username'),
+ verified_by_username: fd.get('verified_by_username'),
+ rating: Number(fd.get('rating')),
+ };
+ const xhr = new XMLHttpRequest();
+ xhr.open('PUT', basePath() + '/api/ticket_update.php', true);
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.onload = function () {
+ let data;
+ try {
+ data = JSON.parse(xhr.responseText);
+ } catch {
+ if (statusEl) statusEl.textContent = 'Invalid response';
+ return;
+ }
+ if (!data.ok) {
+ if (statusEl) statusEl.textContent = data.error || 'Save failed';
+ return;
+ }
+ if (statusEl) statusEl.textContent = 'Saved';
+ };
+ xhr.onerror = function () {
+ if (statusEl) statusEl.textContent = t('reports.network_error');
+ };
+ xhr.send(JSON.stringify(body));
+ });
+ }
+
function initTagEditButtons(tbody, reloadFn) {
reportsReloadRef = reloadFn;
if (!tbody) return;
@@ -1439,7 +1521,7 @@
if (!btn) return;
e.stopPropagation();
e.preventDefault();
- openTagModal(Number(btn.getAttribute('data-report-id')));
+ openTagModal(Number(btn.getAttribute('data-report-id')), 'report');
});
}
@@ -1448,6 +1530,7 @@
initNav();
initTagModal();
initReportDetail();
+ initTicketDetail();
initTagEditorDetail();
initReportsApp();
}
diff --git a/examples/crash_reporter/backend/public/assets/js/tickets.js b/examples/crash_reporter/backend/public/assets/js/tickets.js
new file mode 100644
index 0000000..9b1d565
--- /dev/null
+++ b/examples/crash_reporter/backend/public/assets/js/tickets.js
@@ -0,0 +1,305 @@
+(function () {
+ function t(key, params) {
+ return window.CrashI18n ? window.CrashI18n.t(key, params) : key;
+ }
+
+ function basePath() {
+ return document.body.getAttribute('data-base-path') || '';
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function formatTime(ms) {
+ if (!ms) return '—';
+ const d = new Date(Number(ms));
+ return d.toISOString().replace('T', ' ').slice(0, 19);
+ }
+
+ function formatStars(n) {
+ const score = Math.max(0, Math.min(5, Number(n) || 0));
+ let html =
+ '';
+ for (let i = 1; i <= 5; i++) {
+ html += '';
+ }
+ return html + '';
+ }
+
+ function renderTagPill(tag) {
+ return (
+ '' +
+ escapeHtml(tag.label || tag.id) +
+ ''
+ );
+ }
+
+ function canTagEdit() {
+ return document.body.getAttribute('data-can-tag-edit') === '1';
+ }
+
+ function initTicketsApp() {
+ const app = document.getElementById('tickets-app');
+ const tbody = document.getElementById('tickets-tbody');
+ const statusEl = document.getElementById('tickets-status');
+ const pagination = document.getElementById('tickets-pagination');
+ const perPageSel = document.getElementById('tickets-per-page');
+ const tagFilter = document.getElementById('tickets-tag-filter');
+ const thead = document.querySelector('#tickets-table thead');
+ if (!app || !tbody) return;
+
+ let page = 1;
+ let perPage = 50;
+ let sort = 'opened_at_ms';
+ let dir = 'desc';
+ let tag = '';
+
+ function load() {
+ statusEl.textContent = t('reports.loading');
+ const q =
+ '?page=' +
+ page +
+ '&per_page=' +
+ perPage +
+ '&sort=' +
+ encodeURIComponent(sort) +
+ '&dir=' +
+ encodeURIComponent(dir) +
+ (tag ? '&tag=' + encodeURIComponent(tag) : '');
+ const xhr = new XMLHttpRequest();
+ xhr.open('GET', basePath() + '/api/tickets.php' + q, true);
+ xhr.onload = function () {
+ let data;
+ try {
+ data = JSON.parse(xhr.responseText);
+ } catch {
+ statusEl.textContent = t('reports.invalid_response');
+ return;
+ }
+ if (!data.ok) {
+ statusEl.textContent = data.hint || data.error || t('reports.load_failed');
+ return;
+ }
+ renderRows(data.items || []);
+ renderPagination(data.total || 0, data.page || 1);
+ const from = data.total ? (data.page - 1) * data.per_page + 1 : 0;
+ const to = Math.min(data.page * data.per_page, data.total);
+ statusEl.textContent = t('reports.status_showing', {
+ from,
+ to,
+ total: data.total,
+ });
+ };
+ xhr.onerror = function () {
+ statusEl.textContent = t('reports.network_error');
+ };
+ xhr.send();
+ }
+
+ window.ticketsReloadRef = load;
+
+ function renderRows(items) {
+ if (!items.length) {
+ tbody.innerHTML =
+ '
| ' + escapeHtml(t('tickets.empty')) + ' |
';
+ return;
+ }
+ let html = '';
+ items.forEach((row, i) => {
+ const briefId = 'tkt-brief-' + row.id + '-' + i;
+ const openUrl = basePath() + '/?view=ticket&id=' + row.id;
+ const issueTitle = escapeHtml(row.title || '');
+ const issueBrief = row.brief
+ ? '' + escapeHtml(row.brief) + '
'
+ : '';
+ html +=
+ '';
+ html +=
+ ' | ';
+ html +=
+ '' +
+ issueTitle +
+ '' +
+ issueBrief +
+ ' | ';
+ html += '' + formatTime(row.opened_at_ms) + ' | ';
+ html +=
+ '' +
+ escapeHtml(row.env_brief || '') +
+ ' ' +
+ escapeHtml(row.device_model || '') +
+ ' | ';
+ html += '' + formatStars(row.rating) + ' | ';
+ const tagEditBtn = canTagEdit()
+ ? ''
+ : '';
+ const tags = (row.tags || []).map(renderTagPill).join('');
+ html +=
+ '' +
+ tags +
+ tagEditBtn +
+ ' | ';
+ html += '
';
+ html +=
+ '' +
+ escapeHtml(t('row.open_report')) +
+ ' ';
+ [row.brief, row.env_brief, 'Owner: ' + (row.owner_username || '—')]
+ .filter(Boolean)
+ .forEach((line) => {
+ html += '- ' + escapeHtml(String(line)) + '
';
+ });
+ html += ' |
';
+ });
+ tbody.innerHTML = html;
+ bindRows();
+ }
+
+ function bindRows() {
+ tbody.querySelectorAll('.report-tree-toggle').forEach((btn) => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ const controls = btn.getAttribute('aria-controls');
+ const row = controls ? document.getElementById(controls) : null;
+ if (!row) return;
+ const open = row.hidden;
+ row.hidden = !open;
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
+ });
+ });
+ tbody.querySelectorAll('[data-href]').forEach((el) => {
+ const href = el.getAttribute('data-href');
+ if (!href) return;
+ const go = () => {
+ window.location.href = href;
+ };
+ el.addEventListener('click', (e) => {
+ if (e.target.closest('.report-tree-toggle, .tag-edit-btn')) return;
+ go();
+ });
+ el.addEventListener('keydown', (e) => {
+ if (e.target.closest('.report-tree-toggle, .tag-edit-btn')) return;
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ go();
+ }
+ });
+ });
+ tbody.querySelectorAll('.tag-edit-btn').forEach((btn) => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ if (typeof window.openTagModal === 'function') {
+ window.openTagModal(Number(btn.getAttribute('data-ticket-id')), 'ticket');
+ }
+ });
+ });
+ }
+
+ function renderPagination(total, currentPage) {
+ if (!pagination) return;
+ page = currentPage;
+ const pages = Math.max(1, Math.ceil(total / perPage));
+ const prev = page > 1 ? page - 1 : null;
+ const next = page < pages ? page + 1 : null;
+ let html = '';
+ pagination.innerHTML = html;
+ pagination.querySelectorAll('.page-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const p = Number(btn.getAttribute('data-page'));
+ if (!p) return;
+ page = p;
+ load();
+ });
+ });
+ }
+
+ if (perPageSel) {
+ perPageSel.addEventListener('change', () => {
+ perPage = Number(perPageSel.value) || 50;
+ page = 1;
+ load();
+ });
+ }
+ if (tagFilter) {
+ tagFilter.addEventListener('change', () => {
+ tag = tagFilter.value || '';
+ page = 1;
+ load();
+ });
+ }
+ if (thead) {
+ thead.querySelectorAll('.sortable').forEach((th) => {
+ th.addEventListener('click', () => {
+ const key = th.getAttribute('data-sort');
+ if (!key) return;
+ if (sort === key) {
+ dir = dir === 'asc' ? 'desc' : 'asc';
+ } else {
+ sort = key;
+ dir = 'desc';
+ }
+ thead.querySelectorAll('.sortable').forEach((x) => {
+ x.classList.toggle('sortable--active', x === th);
+ });
+ page = 1;
+ load();
+ });
+ });
+ }
+
+ load();
+ }
+
+ function onReady(fn) {
+ if (window.CrashI18n && window.CrashI18n.ready) {
+ window.CrashI18n.ready.then(fn);
+ } else {
+ fn();
+ }
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => onReady(initTicketsApp));
+ } else {
+ onReady(initTicketsApp);
+ }
+})();
diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php
index 4ee5724..3278f50 100644
--- a/examples/crash_reporter/backend/public/index.php
+++ b/examples/crash_reporter/backend/public/index.php
@@ -45,6 +45,26 @@ if ($route === '/api/report_tags.php' || str_ends_with($route, '/api/report_tags
exit;
}
+if ($route === '/api/ticket_upload.php' || str_ends_with($route, '/api/ticket_upload.php')) {
+ require __DIR__ . '/api/ticket_upload.php';
+ exit;
+}
+
+if ($route === '/api/tickets.php' || str_ends_with($route, '/api/tickets.php')) {
+ require __DIR__ . '/api/tickets.php';
+ exit;
+}
+
+if ($route === '/api/ticket_tags.php' || str_ends_with($route, '/api/ticket_tags.php')) {
+ require __DIR__ . '/api/ticket_tags.php';
+ exit;
+}
+
+if ($route === '/api/ticket_update.php' || str_ends_with($route, '/api/ticket_update.php')) {
+ require __DIR__ . '/api/ticket_update.php';
+ exit;
+}
+
if ($route === '/logout') {
Auth::logout();
header('Location: ' . $base . '/login');
@@ -104,5 +124,28 @@ if ($view === 'report' && isset($_GET['id'])) {
exit;
}
-$pageTitle = $view === 'home' ? 'Home' : 'Crash reports';
+if ($view === 'ticket' && isset($_GET['id'])) {
+ try {
+ $ticket = TicketRepository::getById((int) $_GET['id']);
+ if (!$ticket) {
+ http_response_code(404);
+ echo 'Not found';
+ exit;
+ }
+ $pageTitle = 'Ticket';
+ require __DIR__ . '/../views/layout.php';
+ } catch (Throwable $e) {
+ error_log('ticket view: ' . $e->getMessage());
+ http_response_code(500);
+ echo cfg('debug') ? 'Ticket view failed: ' . $e->getMessage() : 'Ticket view failed';
+ }
+ exit;
+}
+
+$pageTitle = match ($view) {
+ 'home' => 'Home',
+ 'tickets' => 'Tickets',
+ 'reports', 'report' => 'Crash reports',
+ default => 'Console',
+};
require __DIR__ . '/../views/layout.php';
diff --git a/examples/crash_reporter/backend/scripts/ingest_alpha_smoke_tickets.php b/examples/crash_reporter/backend/scripts/ingest_alpha_smoke_tickets.php
new file mode 100644
index 0000000..edffadd
--- /dev/null
+++ b/examples/crash_reporter/backend/scripts/ingest_alpha_smoke_tickets.php
@@ -0,0 +1,159 @@
+#!/usr/bin/env php
+ 'DOOGEE',
+ 'brand' => 'DOOGEE',
+ 'model' => $opts['device'] ?? 'BL6000 Pro',
+ 'sdk_int' => 29,
+ 'release' => '10',
+ 'abis' => ['arm64-v8a', 'armeabi-v7a'],
+];
+$app = [
+ 'package' => 'com.foxx.androidcast',
+ 'version_name' => $opts['app-version'] ?? '0.1.0-alpha',
+ 'version_code' => 1,
+];
+
+function classify_row(array $row): array {
+ $actual = $row['actual'];
+ $pass = (bool) preg_match('/^\s*pass\b/i', $actual) && !preg_match('/\bNOK\b/i', $actual);
+ $needsTicket = !$pass
+ || preg_match('/needs review|remark|poor|low-res|frame drop|lifecycle|VU-meter|laggy|wrong/i', $actual);
+ if (!$needsTicket) {
+ return ['skip' => true];
+ }
+ $status = 'open';
+ $rating = 3;
+ if (preg_match('/\bNOK\b/i', $actual)) {
+ $status = 'urgent';
+ $rating = 4;
+ } elseif ($pass) {
+ $status = 'confirmed';
+ $rating = 2;
+ }
+ if (preg_match('/needs review/i', $actual)) {
+ $status = 'in-progress';
+ }
+ return ['skip' => false, 'status' => $status, 'rating' => $rating];
+}
+
+function ticket_tags(string $status): array {
+ $map = [
+ 'open' => ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'],
+ 'in-progress' => ['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3b82f6'],
+ 'confirmed' => ['id' => 'confirmed', 'label' => 'confirmed', 'bg' => '#047857'],
+ 'urgent' => ['id' => 'urgent', 'label' => 'urgent', 'bg' => '#dc2626'],
+ 'closed' => ['id' => 'closed', 'label' => 'closed', 'bg' => '#6b7280'],
+ ];
+ $tags = [
+ ['id' => 'ticket', 'label' => 'ticket', 'bg' => '#6366f1'],
+ ['id' => 'alpha-smoke', 'label' => 'alpha-smoke', 'bg' => '#8b5cf6'],
+ $map[$status] ?? $map['open'],
+ ];
+ return $tags;
+}
+
+$now = (int) round(microtime(true) * 1000);
+$ok = 0;
+$skip = 0;
+$fail = 0;
+
+foreach ($rows as $row) {
+ $class = classify_row($row);
+ if (!empty($class['skip'])) {
+ $skip++;
+ continue;
+ }
+ $block = $row['block'];
+ $title = 'Alpha smoke ' . $block . ': ' . mb_substr($row['expected'], 0, 80);
+ $brief = mb_substr($row['actual'], 0, 240);
+ $body = "Block: {$block}\n\nExpected:\n{$row['expected']}\n\nActual:\n{$row['actual']}\n\nSource: alpha_smoke_round1.txt";
+ $payload = [
+ 'schema_version' => 1,
+ 'ingest_kind' => 'ticket',
+ 'ticket_id' => 'alpha-smoke-' . strtolower($block) . '-' . substr(sha1($body), 0, 12),
+ 'title' => $title,
+ 'brief' => $brief,
+ 'body' => $body,
+ 'opened_at_epoch_ms' => $now,
+ 'rating' => $class['rating'],
+ 'owner' => $owner,
+ 'verified_by' => '',
+ 'tags' => ticket_tags($class['status']),
+ 'device' => $device,
+ 'app' => $app,
+ 'alpha_block' => $block,
+ 'source_file' => basename($file),
+ 'source_excerpt' => $body,
+ ];
+ $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ if ($dryRun) {
+ echo "DRY $block → {$payload['ticket_id']} [{$class['status']}]\n";
+ $ok++;
+ continue;
+ }
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/json\r\n",
+ 'content' => $json,
+ 'ignore_errors' => true,
+ ],
+ ]);
+ $resp = @file_get_contents($uploadUrl, false, $ctx);
+ $code = 0;
+ if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
+ $code = (int) $m[1];
+ }
+ if ($code === 200) {
+ echo "OK $block\n";
+ $ok++;
+ } elseif ($code === 409) {
+ echo "DUP $block\n";
+ $ok++;
+ } else {
+ echo "FAIL $block HTTP $code — $resp\n";
+ $fail++;
+ }
+}
+
+echo "Done: uploaded=$ok skipped=$skip failed=$fail\n";
+exit($fail > 0 ? 1 : 0);
diff --git a/examples/crash_reporter/backend/sql/migrations/003_tickets.sql b/examples/crash_reporter/backend/sql/migrations/003_tickets.sql
new file mode 100644
index 0000000..1e9e505
--- /dev/null
+++ b/examples/crash_reporter/backend/sql/migrations/003_tickets.sql
@@ -0,0 +1,32 @@
+-- Tickets (tasks / QA / user issues) — same console as crash reports.
+-- Run: mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql
+
+CREATE TABLE IF NOT EXISTS tickets (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ company_id INT UNSIGNED NOT NULL DEFAULT 1,
+ device_id BIGINT UNSIGNED NULL,
+ ticket_id VARCHAR(128) NOT NULL,
+ title VARCHAR(256) NOT NULL,
+ brief TEXT NULL,
+ body LONGTEXT NOT NULL,
+ opened_at_ms BIGINT NOT NULL,
+ created_at_ms BIGINT NOT NULL,
+ updated_at_ms BIGINT NOT NULL,
+ owner_username VARCHAR(64) NOT NULL DEFAULT '',
+ verified_by_username VARCHAR(64) NULL,
+ device_model VARCHAR(128) NULL,
+ app_package VARCHAR(128) NULL,
+ app_version VARCHAR(64) NULL,
+ os_name VARCHAR(64) NULL,
+ os_version VARCHAR(128) NULL,
+ rating TINYINT UNSIGNED NOT NULL DEFAULT 0,
+ tags_json LONGTEXT NOT NULL,
+ payload_json LONGTEXT NOT NULL,
+ UNIQUE KEY uq_tickets_ticket_id (ticket_id),
+ KEY idx_tickets_company (company_id),
+ KEY idx_tickets_opened (opened_at_ms),
+ KEY idx_tickets_updated (updated_at_ms),
+ KEY idx_tickets_owner (owner_username),
+ CONSTRAINT fk_tickets_company FOREIGN KEY (company_id) REFERENCES companies (id),
+ CONSTRAINT fk_tickets_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/examples/crash_reporter/backend/sql/migrations/003_tickets.sqlite.sql b/examples/crash_reporter/backend/sql/migrations/003_tickets.sqlite.sql
new file mode 100644
index 0000000..415edf8
--- /dev/null
+++ b/examples/crash_reporter/backend/sql/migrations/003_tickets.sqlite.sql
@@ -0,0 +1,27 @@
+CREATE TABLE IF NOT EXISTS tickets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ company_id INTEGER NOT NULL DEFAULT 1,
+ device_id INTEGER NULL,
+ ticket_id TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL,
+ brief TEXT,
+ body TEXT NOT NULL,
+ opened_at_ms INTEGER NOT NULL,
+ created_at_ms INTEGER NOT NULL,
+ updated_at_ms INTEGER NOT NULL,
+ owner_username TEXT NOT NULL DEFAULT '',
+ verified_by_username TEXT,
+ device_model TEXT,
+ app_package TEXT,
+ app_version TEXT,
+ os_name TEXT,
+ os_version TEXT,
+ rating INTEGER NOT NULL DEFAULT 0,
+ tags_json TEXT NOT NULL DEFAULT '[]',
+ payload_json TEXT NOT NULL,
+ FOREIGN KEY (company_id) REFERENCES companies(id),
+ FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_tickets_company ON tickets(company_id);
+CREATE INDEX IF NOT EXISTS idx_tickets_opened ON tickets(opened_at_ms DESC);
diff --git a/examples/crash_reporter/backend/src/Auth.php b/examples/crash_reporter/backend/src/Auth.php
index e3cef37..a2fdc1d 100644
--- a/examples/crash_reporter/backend/src/Auth.php
+++ b/examples/crash_reporter/backend/src/Auth.php
@@ -49,6 +49,19 @@ final class Auth {
return Rbac::can('edit_tags', $companyId);
}
+ /** Ticket owner or company admin+ (same gate as tag edit for alpha). */
+ public static function canEditTicket(array $ticket, ?int $companyId = null): bool {
+ if (self::canEditTags($companyId)) {
+ return true;
+ }
+ $user = self::user();
+ if (!$user) {
+ return false;
+ }
+ $owner = trim((string) ($ticket['owner_username'] ?? ''));
+ return $owner !== '' && strcasecmp($owner, (string) ($user['username'] ?? '')) === 0;
+ }
+
public static function login(string $username, string $password): bool {
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1');
diff --git a/examples/crash_reporter/backend/src/Database.php b/examples/crash_reporter/backend/src/Database.php
index f43833b..8f6af1e 100644
--- a/examples/crash_reporter/backend/src/Database.php
+++ b/examples/crash_reporter/backend/src/Database.php
@@ -156,6 +156,25 @@ final class Database {
}
self::ensureMysqlReportViewsTable($pdo);
self::ensureRbacSchema($pdo);
+ self::ensureTicketsTable($pdo);
+ }
+
+ public static function ensureTicketsTable(?PDO $pdo = null): void {
+ $pdo ??= self::$pdo;
+ if ($pdo === null || self::tableExists($pdo, 'tickets')) {
+ return;
+ }
+ if (self::isMysql()) {
+ $sql = file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sql') ?: '';
+ $sql = preg_replace('/^\s*USE\s+[^;]+;\s*/mi', '', $sql) ?? $sql;
+ self::execSchemaSql($pdo, $sql, 'create tickets mysql');
+ return;
+ }
+ self::execSchemaSql(
+ $pdo,
+ file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sqlite.sql') ?: '',
+ 'create tickets sqlite'
+ );
}
/**
diff --git a/examples/crash_reporter/backend/src/TicketRepository.php b/examples/crash_reporter/backend/src/TicketRepository.php
new file mode 100644
index 0000000..b9ce728
--- /dev/null
+++ b/examples/crash_reporter/backend/src/TicketRepository.php
@@ -0,0 +1,274 @@
+prepare(
+ 'INSERT INTO tickets (company_id, device_id, ticket_id, title, brief, body,
+ opened_at_ms, created_at_ms, updated_at_ms, owner_username, verified_by_username,
+ device_model, app_package, app_version, os_name, os_version, rating, tags_json, payload_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ );
+ $stmt->execute([
+ $companyId,
+ $deviceId > 0 ? $deviceId : null,
+ $payload['ticket_id'] ?? uniqid('tkt_', true),
+ mb_substr($title, 0, 256),
+ $brief !== '' ? $brief : null,
+ $body,
+ $opened,
+ $now,
+ $now,
+ mb_substr($owner, 0, 64),
+ $verified !== '' ? mb_substr($verified, 0, 64) : null,
+ $device['model'] ?? null,
+ $app['package'] ?? 'com.foxx.androidcast',
+ $app['version_name'] ?? null,
+ $osName !== '' ? $osName : null,
+ $osVer !== '' ? $osVer : null,
+ $rating,
+ $tagsJson,
+ $json,
+ ]);
+ }
+
+ public static function listPage(int $page, int $perPage, string $sort, string $dir, string $tagFilter = ''): array {
+ $pdo = Database::pdo();
+ $page = max(1, $page);
+ $perPage = max(1, min(200, $perPage));
+ $dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
+ $sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'opened_at_ms';
+ $offset = ($page - 1) * $perPage;
+ $where = ['1=1'];
+ $params = [];
+ self::appendCompanyScope('company_id', $where, $params);
+ if ($tagFilter !== '') {
+ $where[] = 'tags_json LIKE ?';
+ $params[] = '%"id":"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagFilter) . '"%';
+ }
+ $whereSql = implode(' AND ', $where);
+ $countStmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE $whereSql");
+ $countStmt->execute($params);
+ $total = (int) $countStmt->fetchColumn();
+ $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
+ FROM tickets WHERE $whereSql ORDER BY $sortCol $dir LIMIT $perPage OFFSET $offset";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute($params);
+ $items = [];
+ foreach ($stmt->fetchAll() as $row) {
+ $items[] = self::mapListRow($row);
+ }
+ return [
+ 'items' => $items,
+ 'total' => $total,
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'sort' => $sortCol,
+ 'dir' => strtolower($dir),
+ ];
+ }
+
+ public static function getById(int $id): ?array {
+ $pdo = Database::pdo();
+ $where = ['id = ?'];
+ $params = [$id];
+ self::appendCompanyScope('company_id', $where, $params);
+ $stmt = $pdo->prepare(
+ 'SELECT * FROM tickets WHERE ' . implode(' AND ', $where) . ' LIMIT 1'
+ );
+ $stmt->execute($params);
+ $row = $stmt->fetch();
+ if (!$row) {
+ return null;
+ }
+ return self::mapDetailRow($row);
+ }
+
+ public static function setCustomTags(int $id, array $tagsIn): void {
+ $ticket = self::getById($id);
+ if (!$ticket) {
+ throw new RuntimeException('not found');
+ }
+ $err = TicketTagCatalog::validate($tagsIn);
+ if ($err !== null) {
+ throw new InvalidArgumentException($err);
+ }
+ $tags = TicketTagCatalog::normalize($tagsIn);
+ $now = (int) round(microtime(true) * 1000);
+ $pdo = Database::pdo();
+ $stmt = $pdo->prepare('UPDATE tickets SET tags_json = ?, updated_at_ms = ? WHERE id = ?');
+ $stmt->execute([
+ json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+ $now,
+ $id,
+ ]);
+ }
+
+ /** @param array $fields */
+ public static function update(int $id, array $fields): void {
+ $ticket = self::getById($id);
+ if (!$ticket) {
+ throw new RuntimeException('not found');
+ }
+ $sets = [];
+ $params = [];
+ if (isset($fields['title'])) {
+ $title = trim((string) $fields['title']);
+ if ($title === '') {
+ throw new InvalidArgumentException('title required');
+ }
+ $sets[] = 'title = ?';
+ $params[] = mb_substr($title, 0, 256);
+ }
+ if (array_key_exists('brief', $fields)) {
+ $sets[] = 'brief = ?';
+ $params[] = trim((string) $fields['brief']) ?: null;
+ }
+ if (isset($fields['body'])) {
+ $sets[] = 'body = ?';
+ $params[] = (string) $fields['body'];
+ }
+ if (isset($fields['rating'])) {
+ $sets[] = 'rating = ?';
+ $params[] = max(0, min(5, (int) $fields['rating']));
+ }
+ if (isset($fields['owner_username'])) {
+ $sets[] = 'owner_username = ?';
+ $params[] = mb_substr(trim((string) $fields['owner_username']), 0, 64);
+ }
+ if (array_key_exists('verified_by_username', $fields)) {
+ $v = trim((string) $fields['verified_by_username']);
+ $sets[] = 'verified_by_username = ?';
+ $params[] = $v !== '' ? mb_substr($v, 0, 64) : null;
+ }
+ if ($sets === []) {
+ return;
+ }
+ $now = (int) round(microtime(true) * 1000);
+ $sets[] = 'updated_at_ms = ?';
+ $params[] = $now;
+ $params[] = $id;
+ $pdo = Database::pdo();
+ $stmt = $pdo->prepare('UPDATE tickets SET ' . implode(', ', $sets) . ' WHERE id = ?');
+ $stmt->execute($params);
+ }
+
+ public static function listTagPresets(): array {
+ return TicketTagCatalog::presets();
+ }
+
+ private static function appendCompanyScope(string $column, array &$where, array &$params): void {
+ $allowed = Rbac::allowedCompanyIds();
+ if ($allowed === null) {
+ return;
+ }
+ if ($allowed === []) {
+ $where[] = '0=1';
+ return;
+ }
+ $placeholders = implode(',', array_fill(0, count($allowed), '?'));
+ $where[] = "$column IN ($placeholders)";
+ foreach ($allowed as $id) {
+ $params[] = $id;
+ }
+ }
+
+ /** @param array $row */
+ private static function mapListRow(array $row): array {
+ $tags = parse_report_tags($row['tags_json'] ?? '[]');
+ $pkg = (string) ($row['app_package'] ?? 'com.foxx.androidcast');
+ $ver = (string) ($row['app_version'] ?? '');
+ $os = trim(((string) ($row['os_name'] ?? '')) . ' ' . ((string) ($row['os_version'] ?? '')));
+ return [
+ 'id' => (int) $row['id'],
+ 'ticket_id' => (string) $row['ticket_id'],
+ 'title' => (string) $row['title'],
+ 'brief' => (string) ($row['brief'] ?? ''),
+ 'opened_at_ms' => (int) $row['opened_at_ms'],
+ 'updated_at_ms' => (int) $row['updated_at_ms'],
+ 'owner_username' => (string) ($row['owner_username'] ?? ''),
+ 'verified_by_username' => (string) ($row['verified_by_username'] ?? ''),
+ 'device_model' => (string) ($row['device_model'] ?? ''),
+ 'app_package' => $pkg,
+ 'app_version' => $ver,
+ 'os_name' => (string) ($row['os_name'] ?? ''),
+ 'os_version' => (string) ($row['os_version'] ?? ''),
+ 'env_brief' => ticket_env_brief($pkg, $ver, (string) ($row['os_name'] ?? ''), (string) ($row['os_version'] ?? '')),
+ 'rating' => (int) ($row['rating'] ?? 0),
+ 'tags' => $tags,
+ ];
+ }
+
+ /** @param array $row */
+ private static function mapDetailRow(array $row): array {
+ $list = self::mapListRow($row);
+ $payload = json_decode($row['payload_json'] ?? '', true);
+ $list['body'] = (string) ($row['body'] ?? '');
+ $list['created_at_ms'] = (int) ($row['created_at_ms'] ?? 0);
+ $list['payload'] = is_array($payload) ? $payload : [];
+ $list['can_edit'] = Auth::canEditTicket($list);
+ return $list;
+ }
+}
+
+function ticket_format_os_version(array $device): string {
+ $rel = (string) ($device['release'] ?? '');
+ $sdk = (int) ($device['sdk_int'] ?? 0);
+ if ($rel !== '' && $sdk > 0) {
+ return $rel . ' (API ' . $sdk . ')';
+ }
+ if ($rel !== '') {
+ return $rel;
+ }
+ if ($sdk > 0) {
+ return 'API ' . $sdk;
+ }
+ return '';
+}
+
+function ticket_env_brief(string $pkg, string $version, string $osName, string $osVersion): string {
+ $app = str_contains($pkg, 'androidcast') ? 'Android Cast' : ($pkg !== '' ? $pkg : 'app');
+ $line = trim($app . ($version !== '' ? ' ' . $version : ''));
+ $os = trim($osName . ($osVersion !== '' ? ' · ' . $osVersion : ''));
+ return trim($line . ($os !== '' ? ' · ' . $os : ''));
+}
diff --git a/examples/crash_reporter/backend/src/TicketTagCatalog.php b/examples/crash_reporter/backend/src/TicketTagCatalog.php
new file mode 100644
index 0000000..9e86143
--- /dev/null
+++ b/examples/crash_reporter/backend/src/TicketTagCatalog.php
@@ -0,0 +1,80 @@
+ */
+ private const STATUS = [
+ 'open' => ['label' => 'open', 'bg' => '#22c55e'],
+ 'in-progress' => ['label' => 'in progress', 'bg' => '#3b82f6'],
+ 'closed' => ['label' => 'closed', 'bg' => '#6b7280'],
+ 'rejected' => ['label' => 'rejected', 'bg' => '#b91c1c'],
+ 'confirmed' => ['label' => 'confirmed', 'bg' => '#047857'],
+ 'postponed' => ['label' => 'postponed', 'bg' => '#a16207'],
+ 'urgent' => ['label' => 'urgent', 'bg' => '#dc2626'],
+ ];
+
+ /** @return list */
+ public static function statusIds(): array {
+ return array_keys(self::STATUS);
+ }
+
+ /** @return list */
+ public static function presets(): array {
+ $out = [
+ ['id' => self::META_ID, 'label' => 'ticket', 'bg' => '#6366f1'],
+ ];
+ foreach (self::STATUS as $id => $def) {
+ $out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']];
+ }
+ return $out;
+ }
+
+ /**
+ * @param list $tags
+ * @return list
+ */
+ public static function normalize(array $tags): array {
+ $normalized = normalize_report_tags($tags);
+ $hasMeta = false;
+ $hasStatus = false;
+ foreach ($normalized as $t) {
+ if ($t['id'] === self::META_ID) {
+ $hasMeta = true;
+ }
+ if (isset(self::STATUS[$t['id']])) {
+ $hasStatus = true;
+ }
+ }
+ if (!$hasMeta) {
+ $normalized[] = ['id' => self::META_ID, 'label' => 'ticket', 'bg' => '#6366f1'];
+ }
+ if (!$hasStatus) {
+ $normalized[] = ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'];
+ }
+ return $normalized;
+ }
+
+ public static function validate(array $tags): ?string {
+ $normalized = self::normalize($tags);
+ $hasMeta = false;
+ $hasStatus = false;
+ foreach ($normalized as $t) {
+ if ($t['id'] === self::META_ID) {
+ $hasMeta = true;
+ }
+ if (isset(self::STATUS[$t['id']])) {
+ $hasStatus = true;
+ }
+ }
+ if (!$hasMeta) {
+ return 'tags must include ticket meta tag';
+ }
+ if (!$hasStatus) {
+ return 'tags must include a status tag (open, in-progress, closed, …)';
+ }
+ return null;
+ }
+}
diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php
index 50b1b23..2812d97 100644
--- a/examples/crash_reporter/backend/src/bootstrap.php
+++ b/examples/crash_reporter/backend/src/bootstrap.php
@@ -37,6 +37,8 @@ require_once __DIR__ . '/Rbac.php';
require_once __DIR__ . '/DeviceRepository.php';
require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/ReportRepository.php';
+require_once __DIR__ . '/TicketTagCatalog.php';
+require_once __DIR__ . '/TicketRepository.php';
function cfg(string $key, $default = null) {
global $config;
diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php
index 5f67fc0..5604e3e 100644
--- a/examples/crash_reporter/backend/views/layout.php
+++ b/examples/crash_reporter/backend/views/layout.php
@@ -28,11 +28,15 @@
+
+
+
>
+ = (($view ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>
+ = (($view ?? '') === 'ticket' && !empty($ticket['id'])) ? ' data-ticket-id="' . (int) $ticket['id'] . '"' : '' ?>>