From 670b6b5f5f013181785db5d9b7967d9dc17c977e Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 24 May 2026 21:16:27 +0200 Subject: [PATCH] Add crash console tickets (list, detail, upload API). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tickets mirror the reports UI: Issue/Opened/App·OS/Rating/Tags list, report-style detail with tags, device/app/timing cards, workflow fields (owner, verified by, rating), and ticket_upload for ingest including alpha smoke script. Co-authored-by: Cursor --- examples/crash_reporter/backend/README.md | 42 +++ .../backend/public/api/ticket_tags.php | 69 ++++ .../backend/public/api/ticket_update.php | 47 +++ .../backend/public/api/ticket_upload.php | 42 +++ .../backend/public/api/tickets.php | 28 ++ .../backend/public/assets/css/app.css | 40 +++ .../backend/public/assets/i18n/en.json | 29 +- .../backend/public/assets/i18n/ru.json | 4 + .../backend/public/assets/js/app.js | 111 ++++++- .../backend/public/assets/js/tickets.js | 305 ++++++++++++++++++ .../crash_reporter/backend/public/index.php | 45 ++- .../scripts/ingest_alpha_smoke_tickets.php | 159 +++++++++ .../backend/sql/migrations/003_tickets.sql | 32 ++ .../sql/migrations/003_tickets.sqlite.sql | 27 ++ examples/crash_reporter/backend/src/Auth.php | 13 + .../crash_reporter/backend/src/Database.php | 19 ++ .../backend/src/TicketRepository.php | 274 ++++++++++++++++ .../backend/src/TicketTagCatalog.php | 80 +++++ .../crash_reporter/backend/src/bootstrap.php | 2 + .../crash_reporter/backend/views/layout.php | 71 +++- .../backend/views/ticket_detail.php | 205 ++++++++++++ 21 files changed, 1627 insertions(+), 17 deletions(-) create mode 100644 examples/crash_reporter/backend/public/api/ticket_tags.php create mode 100644 examples/crash_reporter/backend/public/api/ticket_update.php create mode 100644 examples/crash_reporter/backend/public/api/ticket_upload.php create mode 100644 examples/crash_reporter/backend/public/api/tickets.php create mode 100644 examples/crash_reporter/backend/public/assets/js/tickets.js create mode 100644 examples/crash_reporter/backend/scripts/ingest_alpha_smoke_tickets.php create mode 100644 examples/crash_reporter/backend/sql/migrations/003_tickets.sql create mode 100644 examples/crash_reporter/backend/sql/migrations/003_tickets.sqlite.sql create mode 100644 examples/crash_reporter/backend/src/TicketRepository.php create mode 100644 examples/crash_reporter/backend/src/TicketTagCatalog.php create mode 100644 examples/crash_reporter/backend/views/ticket_detail.php 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 = '
'; + html += + ''; + html += + 'Page ' + + page + + ' / ' + + pages + + ' · ' + + total + + ' tickets'; + 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 @@ + + + > + + >