Эх сурвалжийг харах

Add ticket audit fixes and read-only ops panel

Maciek 3 долоо хоног өмнө
parent
commit
15f4c29a8a

+ 6 - 3
README.md

@@ -41,9 +41,12 @@ customer e-mail
 | POST | `/tools/get_product_compatibility` | Bearer | Car ↔ product compatibility. |
 | POST | `/tools/get_car_data` | Bearer | Brands / models / engines. |
 | POST | `/tools/new_ticket` | Bearer | Create (or reuse) a ticket, hand off. |
-| GET | `/admin/events` | Bearer (admin) | Recent audit trail. |
-| GET | `/admin/jobs` | Bearer (admin) | Queue stats and dead jobs. |
-| GET | `/admin/messages` | Bearer (admin) | Processed-message records. |
+| GET | `/ops` | — | Read-only operations panel shell; data still requires `ADMIN_TOKEN` in the browser. |
+| GET | `/admin/events` | Bearer (admin) | Recent audit trail; filters: `conversationId`, `eventType`, `limit`. |
+| GET | `/admin/jobs` | Bearer (admin) | Queue stats and jobs; filters: `status`, `limit`. |
+| GET | `/admin/messages` | Bearer (admin) | Processed-message/idempotency records; filters: `conversationId`, `status`, `limit`. |
+| GET | `/admin/tickets` | Bearer (admin) | Ticket records with last event/skipped-message counters. |
+| GET | `/admin/meta` | Bearer (admin) | Non-secret counters/settings for the ops panel. |
 
 `/tools/*` uses `Authorization: Bearer <RELAY_SHARED_SECRET>`.
 `/admin/*` uses `Authorization: Bearer <ADMIN_TOKEN>`; when `ADMIN_TOKEN` is

+ 21 - 1
docs/DEPLOY.md

@@ -178,7 +178,27 @@ no restart and no rebuild. Set it back to `prod` and `up -d` again when done;
 prod mode compiles once and runs `dist/`, which is what should serve real
 traffic.
 
-## Database
+### Read-only ops panel
+
+Daily operational visibility should use the built-in read-only panel:
+
+```text
+https://eks-relay.easyklima.com/ops
+```
+
+The HTML shell contains no data and no token. It asks for `ADMIN_TOKEN`, stores it only in browser `sessionStorage`, and then calls `/admin/*` with `Authorization: Bearer ...`.
+
+The panel shows:
+
+- `/health` and `/ready`,
+- recent audit events,
+- queue/jobs including dead jobs,
+- processed messages/idempotency records,
+- tickets with last event and skipped-message counters.
+
+`/admin/*` remains token-protected; without a bearer token it returns 401.
+
+### Prisma Studio
 
 SQLite at `/home/ubuntu/eks_relay/data/eks_relay.db`, bind-mounted into the
 container at `/app/data`. It is gitignored.

+ 9 - 3
docs/SMOKE_TEST.md

@@ -98,11 +98,17 @@ curl -s -X POST $BASE/webhooks/chatwoot -H 'Content-Type: application/json' -d "
 Confirm in the DB:
 
 ```bash
-curl -s "$BASE/admin/messages?conversationId=1311" -H "Authorization: Bearer $ADMIN" | jq
-curl -s "$BASE/admin/events?conversationId=1311"   -H "Authorization: Bearer $ADMIN" | jq
-curl -s "$BASE/admin/jobs"                          -H "Authorization: Bearer $ADMIN" | jq
+curl -s "$BASE/admin/messages?conversationId=1311" -H "Authorization: Bearer ***" | jq
+curl -s "$BASE/admin/events?conversationId=1311"   -H "Authorization: Bearer ***" | jq
+curl -s "$BASE/admin/jobs"                          -H "Authorization: Bearer ***" | jq
+curl -s "$BASE/admin/tickets"                       -H "Authorization: Bearer ***" | jq
+curl -s "$BASE/admin/meta"                          -H "Authorization: Bearer ***" | jq
 ```
 
+Without a bearer token every `/admin/*` endpoint must return `401`. The read-only
+operator panel shell is at `https://eks-relay.easyklima.com/ops`; it contains no
+data and asks for `ADMIN_TOKEN` in the browser before calling `/admin/*`.
+
 Expect exactly one `ProcessedMessage` row for that message id and exactly one
 job. On the server the same data is available offline:
 

+ 5 - 2
docs/TODO.md

@@ -73,12 +73,15 @@ Po sesji wdrożeniowej zrotować:
 
 ## 4. Podgląd bramki / operacje
 
-Aktualnie dostępne:
+Dostępne są dwa bezpieczne tryby podglądu:
 
+- panel read-only: `GET /ops`,
 - `/admin/events` — audyt decyzji bramki,
 - `/admin/jobs` — kolejka i dead jobs,
 - `/admin/messages` — przetworzone wiadomości/idempotencja,
+- `/admin/tickets` — utworzone/adoptowane tickety,
+- `/admin/meta` — słowniki filtrów i liczniki,
 - `npm run events` — CLI podglądu audytu,
 - Prisma Studio — tylko przez SSH tunnel, nie przez publiczny Traefik.
 
-Do rozważenia później: mały read-only panel operacyjny nad `/admin/*`, bez pełnego dostępu zapisu do bazy jak Prisma Studio.
+Panel `/ops` jest przeznaczony do codziennego podglądu i wymaga `ADMIN_TOKEN` po stronie przeglądarki. Prisma Studio ma pełny dostęp read/write do bazy i zostaje wyłącznie narzędziem technicznego debugowania przez SSH.

+ 5 - 0
prisma/migrations/20260820134408_audit_event_job_id/migration.sql

@@ -0,0 +1,5 @@
+-- AlterTable
+ALTER TABLE "AuditEvent" ADD COLUMN "jobId" TEXT;
+
+-- CreateIndex
+CREATE INDEX "AuditEvent_jobId_idx" ON "AuditEvent"("jobId");

+ 4 - 0
prisma/schema.prisma

@@ -68,6 +68,9 @@ model AuditEvent {
   id             String   @id @default(cuid())
   conversationId Int?
   messageId      String?
+  /// Set when the event belongs to a queued job, so the ops panel can join the
+  /// audit trail to the queue without parsing summary text.
+  jobId          String?
   eventType      String
   summary        String
   /// Redacted JSON metadata.
@@ -77,4 +80,5 @@ model AuditEvent {
   @@index([conversationId])
   @@index([eventType])
   @@index([createdAt])
+  @@index([jobId])
 }

+ 547 - 0
public/ops.html

@@ -0,0 +1,547 @@
+<!doctype html>
+<html lang="pl">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<title>EKSRelay — panel operacyjny</title>
+<style>
+  :root {
+    --bg: #f6f7f9; --panel: #ffffff; --border: #dfe3e8; --text: #1b1f24;
+    --muted: #6a737d; --accent: #2f6feb; --accent-soft: #e8f0fe;
+    --ok: #1a7f37; --warn: #9a6700; --bad: #b42318; --code-bg: #f2f4f7;
+  }
+  @media (prefers-color-scheme: dark) {
+    :root {
+      --bg: #0f1419; --panel: #171d24; --border: #2a323c; --text: #e6edf3;
+      --muted: #8b949e; --accent: #4c8dff; --accent-soft: #16263f;
+      --ok: #3fb950; --warn: #d29922; --bad: #f85149; --code-bg: #10161d;
+    }
+  }
+  * { box-sizing: border-box; }
+  body {
+    margin: 0; background: var(--bg); color: var(--text);
+    font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+  }
+  header {
+    display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
+    padding: 12px 20px; background: var(--panel); border-bottom: 1px solid var(--border);
+    position: sticky; top: 0; z-index: 10;
+  }
+  header h1 { font-size: 16px; margin: 0; font-weight: 650; letter-spacing: -0.01em; }
+  .spacer { flex: 1; }
+  .badge {
+    display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px;
+    border-radius: 999px; font-size: 12px; font-weight: 600;
+    background: var(--accent-soft); color: var(--accent); white-space: nowrap;
+  }
+  .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
+  .dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); }
+  button {
+    font: inherit; padding: 6px 12px; border-radius: 6px; cursor: pointer;
+    border: 1px solid var(--border); background: var(--panel); color: var(--text);
+  }
+  button:hover { border-color: var(--accent); color: var(--accent); }
+  button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+  button.primary:hover { opacity: .9; color: #fff; }
+  input, select {
+    font: inherit; padding: 6px 10px; border-radius: 6px;
+    border: 1px solid var(--border); background: var(--panel); color: var(--text);
+  }
+  main { padding: 20px; max-width: 1500px; margin: 0 auto; }
+  .tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
+  .tabs button.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
+  .filters {
+    display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap;
+    padding: 14px; background: var(--panel); border: 1px solid var(--border);
+    border-radius: 8px; margin-bottom: 16px;
+  }
+  .field { display: flex; flex-direction: column; gap: 4px; }
+  .field label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
+  .card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
+  .card-head {
+    padding: 10px 14px; border-bottom: 1px solid var(--border);
+    display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 13px;
+  }
+  .table-wrap { overflow-x: auto; }
+  table { width: 100%; border-collapse: collapse; font-size: 13px; }
+  th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
+  th { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; white-space: nowrap; }
+  tbody tr:last-child td { border-bottom: none; }
+  tbody tr:hover { background: var(--accent-soft); }
+  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
+  .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
+  .ts { white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
+  .pill {
+    display: inline-block; padding: 2px 8px; border-radius: 999px;
+    font-size: 11px; font-weight: 600; background: var(--code-bg); white-space: nowrap;
+  }
+  .pill.ok { color: var(--ok); } .pill.warn { color: var(--warn); } .pill.bad { color: var(--bad); }
+  .meta { color: var(--muted); font-size: 12px; word-break: break-word; max-width: 420px; }
+  .stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
+  .stat {
+    background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
+    padding: 10px 14px; min-width: 96px;
+  }
+  .stat .k { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
+  .stat .v { font-size: 20px; font-weight: 650; font-variant-numeric: tabular-nums; }
+  .empty, .error { padding: 28px; text-align: center; color: var(--muted); }
+  .error { color: var(--bad); }
+  #gate {
+    position: fixed; inset: 0; background: var(--bg); display: flex;
+    align-items: center; justify-content: center; padding: 20px; z-index: 100;
+  }
+  #gate .box {
+    background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
+    padding: 28px; width: 100%; max-width: 400px;
+  }
+  #gate h2 { margin: 0 0 6px; font-size: 17px; }
+  #gate p { margin: 0 0 18px; color: var(--muted); font-size: 13px; }
+  #gate input { width: 100%; margin-bottom: 12px; }
+  #gate button { width: 100%; }
+  #gate .err { color: var(--bad); font-size: 13px; margin-top: 12px; display: none; }
+  .hint { color: var(--muted); font-size: 12px; margin-top: 14px; }
+  [hidden] { display: none !important; }
+</style>
+</head>
+<body>
+
+<div id="gate">
+  <div class="box">
+    <h2>EKSRelay — panel operacyjny</h2>
+    <p>Podgląd tylko do odczytu. Podaj <code>ADMIN_TOKEN</code>, żeby wczytać dane.</p>
+    <form id="gate-form" autocomplete="off">
+      <input type="password" id="token-input" placeholder="ADMIN_TOKEN" autocomplete="off" spellcheck="false" required>
+      <button type="submit" class="primary">Zaloguj</button>
+    </form>
+    <div class="err" id="gate-err"></div>
+    <div class="hint">
+      Token trzymany jest wyłącznie w <code>sessionStorage</code> tej karty i wysyłany
+      w nagłówku <code>Authorization</code>. Nigdy nie trafia do adresu URL ani do logów.
+    </div>
+  </div>
+</div>
+
+<div id="app" hidden>
+  <header>
+    <h1>EKSRelay</h1>
+    <span class="badge" id="mode-badge">—</span>
+    <span class="badge"><span class="dot" id="health-dot"></span><span id="health-text">health</span></span>
+    <span class="badge"><span class="dot" id="ready-dot"></span><span id="ready-text">ready</span></span>
+    <span class="spacer"></span>
+    <label class="badge" style="cursor:pointer">
+      <input type="checkbox" id="autorefresh"> auto 15&nbsp;s
+    </label>
+    <button id="refresh">Odśwież</button>
+    <button id="logout">Wyloguj</button>
+  </header>
+
+  <main>
+    <div class="stats" id="stats"></div>
+
+    <div class="tabs" id="tabs">
+      <button data-tab="events" class="active">Audyt</button>
+      <button data-tab="jobs">Kolejka</button>
+      <button data-tab="messages">Wiadomości</button>
+      <button data-tab="tickets">Tickety</button>
+      <button data-tab="ready">Zależności</button>
+    </div>
+
+    <div class="filters">
+      <div class="field">
+        <label for="f-conv">conversationId</label>
+        <input id="f-conv" type="number" min="1" placeholder="wszystkie" style="width:140px">
+      </div>
+      <div class="field" id="f-type-wrap">
+        <label for="f-type">typ eventu</label>
+        <select id="f-type" style="width:200px"><option value="">wszystkie</option></select>
+      </div>
+      <div class="field" id="f-status-wrap" hidden>
+        <label for="f-status">status</label>
+        <select id="f-status" style="width:160px"><option value="">wszystkie</option></select>
+      </div>
+      <div class="field">
+        <label for="f-limit">limit</label>
+        <select id="f-limit" style="width:100px">
+          <option>25</option><option selected>50</option><option>100</option><option>200</option>
+        </select>
+      </div>
+      <button class="primary" id="apply">Zastosuj</button>
+      <span class="spacer"></span>
+      <span class="badge" id="updated">—</span>
+    </div>
+
+    <div class="card">
+      <div class="card-head"><span id="card-title">Audyt</span><span class="spacer"></span><span class="pill" id="card-count"></span></div>
+      <div class="table-wrap"><div id="content"><div class="empty">Ładowanie…</div></div></div>
+    </div>
+  </main>
+</div>
+
+<script>
+(function () {
+  'use strict';
+
+  var KEY = 'eksrelay.adminToken';
+  var tab = 'events';
+  var timer = null;
+
+  var $ = function (id) { return document.getElementById(id); };
+  function token() { return sessionStorage.getItem(KEY) || ''; }
+
+  // Every value rendered below goes through this. Audit summaries and job
+  // errors are partly derived from customer-supplied text, so nothing is
+  // injected as markup.
+  function el(tag, cls, text) {
+    var n = document.createElement(tag);
+    if (cls) n.className = cls;
+    if (text !== undefined && text !== null) n.textContent = String(text);
+    return n;
+  }
+
+  function fmtTime(iso) {
+    if (!iso) return '—';
+    var d = new Date(iso);
+    if (isNaN(d)) return String(iso);
+    var p = function (n) { return String(n).padStart(2, '0'); };
+    return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
+           ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
+  }
+
+  function shorten(v, max) {
+    if (v === null || v === undefined) return '—';
+    var s = typeof v === 'string' ? v : JSON.stringify(v);
+    return s.length > max ? s.slice(0, max) + '…' : s;
+  }
+
+  // Token travels in the header only — never as a query parameter, so it can
+  // never end up in an access log, a proxy log or the browser history.
+  function api(path, params) {
+    var url = new URL(path, window.location.origin);
+    Object.keys(params || {}).forEach(function (k) {
+      if (params[k] !== '' && params[k] !== undefined && params[k] !== null) {
+        url.searchParams.set(k, params[k]);
+      }
+    });
+    return fetch(url.toString(), {
+      headers: { Authorization: 'Bearer ' + token() },
+      cache: 'no-store'
+    }).then(function (r) {
+      if (r.status === 401) { logout('Token odrzucony przez serwer.'); throw new Error('unauthorized'); }
+      if (!r.ok) throw new Error('HTTP ' + r.status);
+      return r.json();
+    });
+  }
+
+  function logout(msg) {
+    sessionStorage.removeItem(KEY);
+    stopAuto();
+    $('app').hidden = true;
+    $('gate').hidden = false;
+    if (msg) { $('gate-err').textContent = msg; $('gate-err').style.display = 'block'; }
+  }
+
+  // ---------------------------------------------------------------- renderers
+
+  function table(cols, rows, build) {
+    if (!rows.length) return el('div', 'empty', 'Brak rekordów dla tych filtrów.');
+    var t = el('table');
+    var thead = el('thead'), tr = el('tr');
+    cols.forEach(function (c) {
+      var th = el('th', c.num ? 'num' : null, c.label);
+      tr.appendChild(th);
+    });
+    thead.appendChild(tr); t.appendChild(thead);
+    var tb = el('tbody');
+    rows.forEach(function (row) { tb.appendChild(build(row)); });
+    t.appendChild(tb);
+    return t;
+  }
+
+  function statusPill(status) {
+    var cls = 'pill';
+    if (status === 'done' || status === 'replied') cls += ' ok';
+    else if (status === 'dead' || status === 'failed') cls += ' bad';
+    else if (status === 'spam' || status === 'skipped' || status === 'ticket') cls += ' warn';
+    return el('span', cls, status);
+  }
+
+  function eventPill(type) {
+    var cls = 'pill';
+    if (type === 'reply_sent' || type === 'webhook_accepted') cls += ' ok';
+    else if (type === 'job_dead' || type === 'no_reply') cls += ' bad';
+    else if (type.indexOf('ticket') !== -1 || type.indexOf('spam') !== -1 || type.indexOf('skipped') !== -1) cls += ' warn';
+    return el('span', cls, type);
+  }
+
+  var VIEWS = {
+    events: {
+      title: 'Audyt decyzji bramki',
+      filters: ['conv', 'type', 'limit'],
+      load: function (f) { return api('/admin/events', { conversationId: f.conv, eventType: f.type, limit: f.limit }); },
+      render: function (d) {
+        return table(
+          [{ label: 'Czas' }, { label: 'Typ' }, { label: 'conversationId', num: true },
+           { label: 'messageId' }, { label: 'jobId' }, { label: 'Podsumowanie' }, { label: 'Meta' }],
+          d.events,
+          function (e) {
+            var tr = el('tr');
+            tr.appendChild(el('td', 'ts', fmtTime(e.createdAt)));
+            var td = el('td'); td.appendChild(eventPill(e.eventType)); tr.appendChild(td);
+            tr.appendChild(el('td', 'num mono', e.conversationId === null ? '—' : e.conversationId));
+            tr.appendChild(el('td', 'mono', shorten(e.messageId, 24)));
+            tr.appendChild(el('td', 'mono', shorten(e.jobId, 14)));
+            tr.appendChild(el('td', null, e.summary));
+            tr.appendChild(el('td', 'meta mono', e.meta ? shorten(e.meta, 160) : '—'));
+            return tr;
+          }
+        );
+      }
+    },
+
+    jobs: {
+      title: 'Kolejka zadań',
+      filters: ['status', 'limit'],
+      statuses: ['queued', 'processing', 'done', 'dead'],
+      load: function (f) { return api('/admin/jobs', { status: f.status, limit: f.limit }); },
+      render: function (d) {
+        return table(
+          [{ label: 'Zaktualizowano' }, { label: 'Status' }, { label: 'Typ' },
+           { label: 'Próby', num: true }, { label: 'Ponów po' }, { label: 'Ostatni błąd' }, { label: 'id' }],
+          d.jobs,
+          function (j) {
+            var tr = el('tr');
+            tr.appendChild(el('td', 'ts', fmtTime(j.updatedAt)));
+            var td = el('td'); td.appendChild(statusPill(j.status)); tr.appendChild(td);
+            tr.appendChild(el('td', null, j.type));
+            tr.appendChild(el('td', 'num mono', j.attempts + '/' + j.maxAttempts));
+            tr.appendChild(el('td', 'ts', j.status === 'queued' ? fmtTime(j.runAfter) : '—'));
+            tr.appendChild(el('td', 'meta mono', j.lastError ? shorten(j.lastError, 200) : '—'));
+            tr.appendChild(el('td', 'mono', shorten(j.id, 14)));
+            return tr;
+          }
+        );
+      }
+    },
+
+    messages: {
+      title: 'Przetworzone wiadomości (idempotencja)',
+      filters: ['conv', 'status', 'limit'],
+      statuses: ['queued', 'processing', 'replied', 'skipped', 'ticket', 'failed', 'spam'],
+      load: function (f) { return api('/admin/messages', { conversationId: f.conv, status: f.status, limit: f.limit }); },
+      render: function (d) {
+        return table(
+          [{ label: 'Utworzono' }, { label: 'Status' }, { label: 'Powód' },
+           { label: 'conversationId', num: true }, { label: 'messageId' }, { label: 'Źródło' }, { label: 'Zmieniono' }],
+          d.messages,
+          function (m) {
+            var tr = el('tr');
+            tr.appendChild(el('td', 'ts', fmtTime(m.createdAt)));
+            var td = el('td'); td.appendChild(statusPill(m.status)); tr.appendChild(td);
+            tr.appendChild(el('td', null, m.reason || '—'));
+            tr.appendChild(el('td', 'num mono', m.conversationId));
+            tr.appendChild(el('td', 'mono', shorten(m.messageId, 28)));
+            tr.appendChild(el('td', null, m.source));
+            tr.appendChild(el('td', 'ts', fmtTime(m.updatedAt)));
+            return tr;
+          }
+        );
+      }
+    },
+
+    tickets: {
+      title: 'Tickety / handoff',
+      filters: ['conv', 'limit'],
+      load: function (f) { return api('/admin/tickets', { conversationId: f.conv, limit: f.limit }); },
+      render: function (d) {
+        return table(
+          [{ label: 'Utworzono' }, { label: 'Numer ticketu' }, { label: 'conversationId', num: true },
+           { label: 'Powód' }, { label: 'Ostatni event' }, { label: 'Kiedy' }, { label: 'Pominiętych od', num: true }],
+          d.tickets,
+          function (t) {
+            var tr = el('tr');
+            tr.appendChild(el('td', 'ts', fmtTime(t.createdAt)));
+            tr.appendChild(el('td', 'mono', t.ticketNumber));
+            tr.appendChild(el('td', 'num mono', t.conversationId));
+            tr.appendChild(el('td', 'meta', shorten(t.reason, 90)));
+            var td = el('td');
+            if (t.lastEventType) td.appendChild(eventPill(t.lastEventType)); else td.textContent = '—';
+            tr.appendChild(td);
+            tr.appendChild(el('td', 'ts', fmtTime(t.lastEventAt)));
+            tr.appendChild(el('td', 'num mono', t.messagesSkippedSince));
+            return tr;
+          }
+        );
+      }
+    },
+
+    ready: {
+      title: 'Zależności (/ready)',
+      filters: [],
+      load: function () { return fetch('/ready', { cache: 'no-store' }).then(function (r) { return r.json(); }); },
+      render: function (d) {
+        var deps = d.dependencies || {};
+        var rows = Object.keys(deps).map(function (k) { return [k, deps[k]]; });
+        return table(
+          [{ label: 'Zależność' }, { label: 'Stan' }, { label: 'HTTP', num: true }, { label: 'Cel' }],
+          rows,
+          function (pair) {
+            var tr = el('tr');
+            tr.appendChild(el('td', null, pair[0]));
+            var td = el('td');
+            td.appendChild(el('span', 'pill ' + (pair[1].ok ? 'ok' : 'bad'), pair[1].ok ? 'ok' : 'błąd'));
+            tr.appendChild(td);
+            tr.appendChild(el('td', 'num mono', pair[1].status));
+            tr.appendChild(el('td', 'mono', pair[1].target));
+            return tr;
+          }
+        );
+      }
+    }
+  };
+
+  // -------------------------------------------------------------------- state
+
+  function currentFilters() {
+    return {
+      conv: $('f-conv').value.trim(),
+      type: $('f-type').value,
+      status: $('f-status').value,
+      limit: $('f-limit').value
+    };
+  }
+
+  function syncFilterVisibility() {
+    var v = VIEWS[tab];
+    $('f-conv').parentElement.hidden = v.filters.indexOf('conv') === -1;
+    $('f-type-wrap').hidden = v.filters.indexOf('type') === -1;
+    $('f-status-wrap').hidden = v.filters.indexOf('status') === -1;
+    $('f-limit').parentElement.hidden = v.filters.indexOf('limit') === -1;
+
+    var sel = $('f-status');
+    sel.innerHTML = '';
+    sel.appendChild(new Option('wszystkie', ''));
+    (v.statuses || []).forEach(function (s) { sel.appendChild(new Option(s, s)); });
+  }
+
+  function setContent(node) {
+    var c = $('content');
+    c.innerHTML = '';
+    c.appendChild(node);
+  }
+
+  function loadTab() {
+    var v = VIEWS[tab];
+    $('card-title').textContent = v.title;
+    setContent(el('div', 'empty', 'Ładowanie…'));
+    v.load(currentFilters()).then(function (d) {
+      setContent(v.render(d));
+      var n = d.count !== undefined ? d.count : (d.dependencies ? Object.keys(d.dependencies).length : 0);
+      $('card-count').textContent = n + ' rekordów';
+      $('updated').textContent = 'odświeżono ' + fmtTime(new Date().toISOString());
+    }).catch(function (err) {
+      if (err.message === 'unauthorized') return;
+      setContent(el('div', 'error', 'Błąd pobierania: ' + err.message));
+    });
+  }
+
+  function loadHeader() {
+    fetch('/health', { cache: 'no-store' })
+      .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error(r.status)); })
+      .then(function (d) {
+        $('health-dot').className = 'dot ok';
+        $('health-text').textContent = 'health ' + d.uptimeSeconds + 's';
+        $('mode-badge').textContent = 'tryb: ' + d.mode + ' · v' + d.version;
+      })
+      .catch(function () { $('health-dot').className = 'dot bad'; $('health-text').textContent = 'health błąd'; });
+
+    fetch('/ready', { cache: 'no-store' })
+      .then(function (r) { return r.json(); })
+      .then(function (d) {
+        $('ready-dot').className = 'dot ' + (d.ok ? 'ok' : 'bad');
+        var bad = Object.keys(d.dependencies || {}).filter(function (k) { return !d.dependencies[k].ok; });
+        $('ready-text').textContent = bad.length ? 'ready: ' + bad.join(', ') : 'ready ok';
+      })
+      .catch(function () { $('ready-dot').className = 'dot bad'; $('ready-text').textContent = 'ready błąd'; });
+
+    api('/admin/meta').then(function (d) {
+      var sel = $('f-type'), keep = sel.value;
+      sel.innerHTML = '';
+      sel.appendChild(new Option('wszystkie', ''));
+      (d.eventTypes || []).forEach(function (t) { sel.appendChild(new Option(t, t)); });
+      sel.value = keep;
+
+      var s = $('stats');
+      s.innerHTML = '';
+      var jobs = d.counts.jobs || {};
+      [['Eventy', d.counts.auditEvents], ['Tickety', d.counts.tickets],
+       ['W kolejce', jobs.queued || 0], ['Wykonane', jobs.done || 0], ['Dead', jobs.dead || 0]]
+        .forEach(function (pair) {
+          var box = el('div', 'stat');
+          box.appendChild(el('div', 'k', pair[0]));
+          var v = el('div', 'v', pair[1]);
+          if (pair[0] === 'Dead' && pair[1] > 0) v.style.color = 'var(--bad)';
+          box.appendChild(v);
+          s.appendChild(box);
+        });
+      var st = d.settings || {};
+      var box = el('div', 'stat');
+      box.appendChild(el('div', 'k', 'Ustawienia'));
+      box.appendChild(el('div', 'meta',
+        'label=' + st.ticketLabel + ' · unassign=' + st.unassignOnTicket +
+        ' · spamGate=' + st.spamGate + ' · worker=' + st.worker + ' · logPii=' + st.logPii));
+      s.appendChild(box);
+    }).catch(function () { /* handled by api() */ });
+  }
+
+  function refresh() { loadHeader(); loadTab(); }
+
+  function startAuto() { stopAuto(); timer = setInterval(refresh, 15000); }
+  function stopAuto() { if (timer) { clearInterval(timer); timer = null; } }
+
+  function enterApp() {
+    $('gate').hidden = true;
+    $('app').hidden = false;
+    syncFilterVisibility();
+    refresh();
+  }
+
+  // ------------------------------------------------------------------- wiring
+
+  $('gate-form').addEventListener('submit', function (e) {
+    e.preventDefault();
+    var value = $('token-input').value.trim();
+    if (!value) return;
+    sessionStorage.setItem(KEY, value);
+    $('token-input').value = '';
+    $('gate-err').style.display = 'none';
+    // Probe with a real request so a wrong token fails at the gate, not later.
+    api('/admin/meta').then(enterApp).catch(function (err) {
+      if (err.message !== 'unauthorized') {
+        $('gate-err').textContent = 'Błąd połączenia: ' + err.message;
+        $('gate-err').style.display = 'block';
+      }
+    });
+  });
+
+  $('tabs').addEventListener('click', function (e) {
+    var btn = e.target.closest('button[data-tab]');
+    if (!btn) return;
+    Array.prototype.forEach.call($('tabs').children, function (b) { b.classList.remove('active'); });
+    btn.classList.add('active');
+    tab = btn.dataset.tab;
+    syncFilterVisibility();
+    loadTab();
+  });
+
+  $('apply').addEventListener('click', loadTab);
+  $('f-conv').addEventListener('keydown', function (e) { if (e.key === 'Enter') loadTab(); });
+  $('refresh').addEventListener('click', refresh);
+  $('logout').addEventListener('click', function () { logout(); });
+  $('autorefresh').addEventListener('change', function (e) { e.target.checked ? startAuto() : stopAuto(); });
+
+  if (token()) enterApp(); else $('token-input').focus();
+})();
+</script>
+</body>
+</html>

+ 8 - 8
src/domain/conversationPipeline.ts

@@ -5,7 +5,7 @@ import { logger } from '../logger.js';
 import { audit } from '../store/auditLog.js';
 import { setMessageStatus } from '../store/idempotencyStore.js';
 import { evaluateSpam } from './spamGate.js';
-import { createTicket, isTicketMode } from './ticketService.js';
+import { createTicket, hasLocalTicket, isTicketMode } from './ticketService.js';
 import type { SupportMessageEvent } from './messageNormalizer.js';
 
 export interface PipelineDeps {
@@ -83,6 +83,12 @@ export async function processMessageEvent(
     }
   }
 
+  // Snapshot before the LLM turn: the agent may call /tools/new_ticket while
+  // Flowise is thinking, and afterwards we need to tell "ticketed just now"
+  // apart from "a ticket row already existed from an earlier, since-reopened
+  // conversation".
+  const hadTicketBefore = await hasLocalTicket(event.conversationId);
+
   const payload = buildFlowisePayload(event);
   const response = await flowise.predict(payload);
 
@@ -113,7 +119,7 @@ export async function processMessageEvent(
     // still has to reach the customer. Later messages are stopped by the check
     // at the top of this function.
     await chatwoot.sendOutgoingMessage(event.conversationId, response.text);
-    const becameTicket = await wasTicketedDuringCall(event.conversationId);
+    const becameTicket = !hadTicketBefore && (await hasLocalTicket(event.conversationId));
     await setMessageStatus(
       event.source,
       event.messageId,
@@ -141,12 +147,6 @@ export async function processMessageEvent(
   return { action: 'no_reply', reason: 'empty_flowise_response' };
 }
 
-async function wasTicketedDuringCall(conversationId: number): Promise<boolean> {
-  const { db } = await import('../store/db.js');
-  const t = await db().ticket.findUnique({ where: { conversationId } });
-  return t !== null;
-}
-
 /**
  * Flowise payload. `[CONTACT_INFO]` is a contract with the deployed custom
  * tools: `get_order_data` parses `contact_email:` out of `$flow.input`.

+ 68 - 18
src/domain/ticketService.ts

@@ -42,20 +42,26 @@ export function isTicketMode(conv: Partial<ChatwootConversation>): boolean {
   return false;
 }
 
+/** True when this conversation already has a ticket recorded locally. */
+export async function hasLocalTicket(conversationId: number): Promise<boolean> {
+  return (await db().ticket.findUnique({ where: { conversationId } })) !== null;
+}
+
 /**
  * Create (or return) the ticket for a conversation.
  *
- * Idempotency has two layers: the local Ticket table keyed by conversationId,
- * and a re-read of the live Chatwoot attributes — so a ticket created by
- * someone else in the panel is adopted rather than duplicated.
+ * Idempotency has three layers, in order:
+ *   1. the local `Ticket` row, unique per conversationId;
+ *   2. an existing `ticket_number` in Chatwoot, which is adopted rather than
+ *      overwritten — so a ticket opened by a human in the panel still wins;
+ *   3. the unique constraint itself, which decides the winner when two
+ *      `new_ticket` calls for one conversation race each other.
  */
 export async function createTicket(
   conversationId: number,
   reason?: string,
   chatwoot = new ChatwootClient(),
 ): Promise<TicketResult> {
-  const cfg = config();
-
   const local = await db().ticket.findUnique({ where: { conversationId } });
   if (local) {
     logger.info('Ticket already recorded locally', { conversationId });
@@ -67,9 +73,10 @@ export async function createTicket(
   const existingNumber = String(conv.custom_attributes?.ticket_number ?? '').trim();
 
   if (existingNumber && existingNumber !== '0') {
-    await db().ticket.create({
-      data: { conversationId, ticketNumber: existingNumber, reason: reason ?? 'adopted_existing' },
-    });
+    const adopted = await recordTicket(conversationId, existingNumber, reason ?? 'adopted_existing');
+    if (adopted.raced) {
+      return { ok: true, ticketNumber: adopted.ticketNumber, status: 'existing' };
+    }
     await ensureChatwootTicketState(chatwoot, conversationId, existingNumber);
     await audit({
       conversationId,
@@ -81,33 +88,75 @@ export async function createTicket(
 
   const ticketNumber = formatTicketNumber(conversationId);
 
+  // Claim the local row first: it is the cheapest place to lose a race, and
+  // losing it here means no duplicate writes reach Chatwoot at all.
+  const claim = await recordTicket(conversationId, ticketNumber, reason ?? null);
+  if (claim.raced) {
+    logger.info('Concurrent new_ticket lost the race — returning the winning ticket', {
+      conversationId,
+    });
+    return { ok: true, ticketNumber: claim.ticketNumber, status: 'existing' };
+  }
+
   await chatwoot.setCustomAttributes(conversationId, {
     ticket_number: ticketNumber,
     handoff: true,
     ...(reason ? { handoff_reason: reason.slice(0, 200) } : {}),
   });
-  await chatwoot.addLabel(conversationId, cfg.CHATWOOT_TICKET_LABEL);
+  await chatwoot.addLabel(conversationId, config().CHATWOOT_TICKET_LABEL);
 
-  if (cfg.CHATWOOT_UNASSIGN_ON_TICKET) {
-    await chatwoot.unassignConversation(conversationId);
-  }
-
-  await db().ticket.create({
-    data: { conversationId, ticketNumber, reason: reason ?? null },
-  });
+  const unassigned = await applyUnassign(chatwoot, conversationId);
 
   await audit({
     conversationId,
     eventType: 'ticket_created',
     summary: `Ticket ${ticketNumber} created`,
-    meta: { reason: reason ?? null, unassigned: cfg.CHATWOOT_UNASSIGN_ON_TICKET },
+    // `unassigned` is the real outcome of the API call, not the config flag —
+    // an audit trail that reports intent instead of effect is worthless.
+    meta: { reason: reason ?? null, unassignRequested: config().CHATWOOT_UNASSIGN_ON_TICKET, unassigned },
   });
 
   logger.info('Ticket created', { conversationId, ticketNumber });
   return { ok: true, ticketNumber, status: 'created' };
 }
 
-/** Re-apply label/attributes when a locally known ticket lost them in Chatwoot. */
+/**
+ * Insert the local ticket row, treating a unique-constraint collision as
+ * "somebody else got here first" rather than an error.
+ */
+async function recordTicket(
+  conversationId: number,
+  ticketNumber: string,
+  reason: string | null,
+): Promise<{ raced: boolean; ticketNumber: string }> {
+  try {
+    await db().ticket.create({ data: { conversationId, ticketNumber, reason } });
+    return { raced: false, ticketNumber };
+  } catch (err) {
+    if (!isUniqueViolation(err)) throw err;
+    const winner = await db().ticket.findUnique({ where: { conversationId } });
+    return { raced: true, ticketNumber: winner?.ticketNumber ?? ticketNumber };
+  }
+}
+
+function isUniqueViolation(err: unknown): boolean {
+  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002';
+}
+
+/** Unassign when configured to; returns whether Chatwoot actually accepted it. */
+async function applyUnassign(
+  chatwoot: ChatwootClient,
+  conversationId: number,
+): Promise<boolean | null> {
+  if (!config().CHATWOOT_UNASSIGN_ON_TICKET) return null;
+  return chatwoot.unassignConversation(conversationId);
+}
+
+/**
+ * Re-apply label/attributes when a locally known ticket lost them in Chatwoot.
+ * Also re-applies the unassign, so an adopted or repeated ticket ends in the
+ * same Chatwoot state as a freshly created one.
+ */
 async function ensureChatwootTicketState(
   chatwoot: ChatwootClient,
   conversationId: number,
@@ -122,6 +171,7 @@ async function ensureChatwootTicketState(
       });
     }
     await chatwoot.addLabel(conversationId, config().CHATWOOT_TICKET_LABEL);
+    await applyUnassign(chatwoot, conversationId);
   } catch {
     logger.warn('Could not re-assert ticket state in Chatwoot', { conversationId });
   }

+ 2 - 0
src/http/app.ts

@@ -3,6 +3,7 @@ import { healthRouter } from './routes/health.js';
 import { webhookRouter } from './routes/webhooks.js';
 import { toolsRouter } from './routes/tools.js';
 import { adminRouter } from './routes/admin.js';
+import { opsRouter } from './routes/ops.js';
 import { requestLog } from './middleware/requestLog.js';
 import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
 
@@ -18,6 +19,7 @@ export function createApp(): express.Express {
   app.use(healthRouter);
   app.use(webhookRouter);
   app.use(toolsRouter);
+  app.use(opsRouter);
   app.use(adminRouter);
 
   app.use(notFoundHandler);

+ 159 - 23
src/http/routes/admin.ts

@@ -1,25 +1,50 @@
-import { Router } from 'express';
+import { Router, type Request } from 'express';
 import { requireAdminAuth } from '../middleware/auth.js';
-import { recentEvents } from '../../store/auditLog.js';
+import { knownEventTypes, recentEvents } from '../../store/auditLog.js';
 import { queueStats } from '../../queue/jobQueue.js';
 import { db } from '../../store/db.js';
+import { config } from '../../config.js';
 
 export const adminRouter = Router();
 
+/**
+ * Read-only operational API. Every route is behind ADMIN_TOKEN and every
+ * handler is a SELECT — there is deliberately no write path here, which is the
+ * whole reason this exists instead of exposing Prisma Studio.
+ */
 adminRouter.use('/admin', requireAdminAuth);
 
-/** Recent audit trail. Metadata was redacted on write, so nothing secret can surface here. */
+const MAX_LIMIT = 200;
+
+function limitOf(req: Request, fallback = 50): number {
+  const raw = Number(req.query.limit ?? fallback);
+  if (!Number.isFinite(raw)) return fallback;
+  return Math.min(Math.max(Math.trunc(raw), 1), MAX_LIMIT);
+}
+
+function conversationIdOf(req: Request): number | undefined {
+  const raw = req.query.conversationId;
+  if (raw === undefined || raw === '') return undefined;
+  const n = Number(raw);
+  return Number.isFinite(n) ? Math.trunc(n) : undefined;
+}
+
+function stringOf(req: Request, key: string): string | undefined {
+  const raw = req.query[key];
+  if (typeof raw !== 'string') return undefined;
+  const trimmed = raw.trim();
+  return trimmed === '' ? undefined : trimmed;
+}
+
+// ───────────────────────────────────────────────────────── GET /admin/events
+
 adminRouter.get('/admin/events', async (req, res, next) => {
   try {
-    const limit = Number(req.query.limit ?? 50);
-    const conversationId = req.query.conversationId
-      ? Number(req.query.conversationId)
-      : undefined;
-
-    const events = await recentEvents(
-      Number.isFinite(limit) ? limit : 50,
-      conversationId && Number.isFinite(conversationId) ? conversationId : undefined,
-    );
+    const events = await recentEvents({
+      limit: limitOf(req),
+      conversationId: conversationIdOf(req),
+      eventType: stringOf(req, 'eventType'),
+    });
 
     res.json({
       ok: true,
@@ -28,6 +53,7 @@ adminRouter.get('/admin/events', async (req, res, next) => {
         id: e.id,
         conversationId: e.conversationId,
         messageId: e.messageId,
+        jobId: e.jobId,
         eventType: e.eventType,
         summary: e.summary,
         meta: e.metaJson ? safeParse(e.metaJson) : null,
@@ -39,32 +65,142 @@ adminRouter.get('/admin/events', async (req, res, next) => {
   }
 });
 
-adminRouter.get('/admin/jobs', async (_req, res, next) => {
+// ─────────────────────────────────────────────────────────── GET /admin/jobs
+
+adminRouter.get('/admin/jobs', async (req, res, next) => {
   try {
-    const [stats, dead] = await Promise.all([
+    const status = stringOf(req, 'status');
+    const [stats, jobs] = await Promise.all([
       queueStats(),
       db().job.findMany({
-        where: { status: 'dead' },
+        where: status ? { status } : undefined,
         orderBy: { updatedAt: 'desc' },
-        take: 20,
-        select: { id: true, type: true, attempts: true, lastError: true, updatedAt: true },
+        take: limitOf(req, 25),
+        select: {
+          id: true,
+          type: true,
+          status: true,
+          attempts: true,
+          maxAttempts: true,
+          lastError: true,
+          runAfter: true,
+          createdAt: true,
+          updatedAt: true,
+          finishedAt: true,
+        },
       }),
     ]);
-    res.json({ ok: true, stats, dead });
+
+    // `dead` stays a top-level field: it is the one thing an operator must not
+    // have to go looking for.
+    const dead = jobs.filter((j) => j.status === 'dead');
+
+    res.json({ ok: true, stats, count: jobs.length, jobs, dead });
   } catch (err) {
     next(err);
   }
 });
 
+// ─────────────────────────────────────────────────────── GET /admin/messages
+
 adminRouter.get('/admin/messages', async (req, res, next) => {
   try {
-    const conversationId = req.query.conversationId ? Number(req.query.conversationId) : undefined;
-    const rows = await db().processedMessage.findMany({
-      where: conversationId && Number.isFinite(conversationId) ? { conversationId } : undefined,
+    const conversationId = conversationIdOf(req);
+    const status = stringOf(req, 'status');
+
+    const messages = await db().processedMessage.findMany({
+      where: {
+        ...(conversationId !== undefined ? { conversationId } : {}),
+        ...(status ? { status } : {}),
+      },
       orderBy: { createdAt: 'desc' },
-      take: 50,
+      take: limitOf(req),
+    });
+
+    res.json({ ok: true, count: messages.length, messages });
+  } catch (err) {
+    next(err);
+  }
+});
+
+// ──────────────────────────────────────────────────────── GET /admin/tickets
+
+adminRouter.get('/admin/tickets', async (req, res, next) => {
+  try {
+    const conversationId = conversationIdOf(req);
+
+    const tickets = await db().ticket.findMany({
+      where: conversationId !== undefined ? { conversationId } : undefined,
+      orderBy: { createdAt: 'desc' },
+      take: limitOf(req),
+    });
+
+    // A Ticket row has no status column of its own; what an operator actually
+    // wants to know is what the gateway last did on that conversation.
+    const enriched = await Promise.all(
+      tickets.map(async (t) => {
+        const lastEvent = await db().auditEvent.findFirst({
+          where: { conversationId: t.conversationId },
+          orderBy: { createdAt: 'desc' },
+          select: { eventType: true, createdAt: true },
+        });
+        const skippedSince = await db().processedMessage.count({
+          where: {
+            conversationId: t.conversationId,
+            status: 'skipped',
+            createdAt: { gte: t.createdAt },
+          },
+        });
+        return {
+          ...t,
+          lastEventType: lastEvent?.eventType ?? null,
+          lastEventAt: lastEvent?.createdAt ?? null,
+          messagesSkippedSince: skippedSince,
+        };
+      }),
+    );
+
+    res.json({ ok: true, count: enriched.length, tickets: enriched });
+  } catch (err) {
+    next(err);
+  }
+});
+
+// ─────────────────────────────────────────────────────────── GET /admin/meta
+
+/** Filter vocabulary and counters for the ops panel. Contains no secrets. */
+adminRouter.get('/admin/meta', async (_req, res, next) => {
+  try {
+    const cfg = config();
+    const [eventTypes, stats, messages, tickets, events] = await Promise.all([
+      knownEventTypes(),
+      queueStats(),
+      db().processedMessage.groupBy({ by: ['status'], _count: { _all: true } }),
+      db().ticket.count(),
+      db().auditEvent.count(),
+    ]);
+
+    res.json({
+      ok: true,
+      eventTypes,
+      jobStatuses: ['queued', 'processing', 'done', 'dead'],
+      messageStatuses: messages.map((m) => m.status),
+      counts: {
+        auditEvents: events,
+        tickets,
+        jobs: stats,
+        messages: Object.fromEntries(messages.map((m) => [m.status, m._count._all])),
+      },
+      settings: {
+        mode: cfg.RELAY_MODE,
+        ticketLabel: cfg.CHATWOOT_TICKET_LABEL,
+        ticketPrefix: cfg.TICKET_NUMBER_PREFIX,
+        unassignOnTicket: cfg.CHATWOOT_UNASSIGN_ON_TICKET,
+        spamGate: cfg.SPAM_GATE_ENABLED,
+        worker: cfg.WORKER_ENABLED,
+        logPii: cfg.LOG_PII,
+      },
     });
-    res.json({ ok: true, count: rows.length, messages: rows });
   } catch (err) {
     next(err);
   }

+ 39 - 0
src/http/routes/ops.ts

@@ -0,0 +1,39 @@
+import path from 'node:path';
+import { Router } from 'express';
+
+/**
+ * Serves the read-only operations panel.
+ *
+ * The HTML shell itself is public on purpose: it has to render the token
+ * prompt before any credential exists. It contains no data, no token and no
+ * configuration — every byte of information the panel shows is fetched
+ * client-side from `/admin/*` with an `Authorization: Bearer` header, so an
+ * unauthenticated visitor sees an empty login box and nothing else.
+ *
+ * A single explicit file is served rather than `express.static` over a
+ * directory: there is exactly one asset, and this leaves no room for directory
+ * listing or path traversal.
+ */
+export const opsRouter = Router();
+
+// `src/http/routes` and `dist/http/routes` sit at the same depth below the
+// project root, so this resolves correctly for both `tsx` and the compiled build.
+const OPS_HTML = path.resolve(import.meta.dirname, '../../../public/ops.html');
+
+// Deliberately NOT mounted under /admin: that namespace is uniformly
+// token-protected, and an unauthenticated exception inside it would be an easy
+// thing to misread later when auditing the routes.
+opsRouter.get('/ops', (_req, res, next) => {
+  res.setHeader('Cache-Control', 'no-store');
+  res.setHeader('X-Robots-Tag', 'noindex, nofollow');
+  res.setHeader('Referrer-Policy', 'no-referrer');
+  // The panel is entirely self-contained; forbid any outbound subresource so a
+  // future edit cannot accidentally ship the token to a third party.
+  res.setHeader(
+    'Content-Security-Policy',
+    "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
+  );
+  res.sendFile(OPS_HTML, (err) => {
+    if (err) next(err);
+  });
+});

+ 35 - 1
src/http/routes/webhooks.ts

@@ -1,7 +1,8 @@
 import { Router } from 'express';
 import { chatwootWebhookSchema } from '../../types/chatwoot.js';
 import { normalizeChatwootWebhook } from '../../domain/messageNormalizer.js';
-import { claimMessage } from '../../store/idempotencyStore.js';
+import { isTicketMode } from '../../domain/ticketService.js';
+import { claimMessage, setMessageStatus } from '../../store/idempotencyStore.js';
 import { enqueue } from '../../queue/jobQueue.js';
 import { audit } from '../../store/auditLog.js';
 import { logger } from '../../logger.js';
@@ -56,11 +57,44 @@ webhookRouter.post('/webhooks/chatwoot', async (req, res, next) => {
       return;
     }
 
+    // Fast path: when the payload already carries labels and custom attributes,
+    // a handed-off conversation can be settled here — same data the worker
+    // would have used, minus a queue round-trip. Conversations that need a
+    // Chatwoot fetch are still decided in the worker.
+    if (!event.needsConversationFetch) {
+      const ticketed = isTicketMode({
+        labels: event.labels,
+        custom_attributes: event.customAttributes,
+      });
+      if (ticketed) {
+        await setMessageStatus(event.source, event.messageId, 'skipped', 'ticket_mode');
+        await audit({
+          conversationId: event.conversationId,
+          messageId: event.messageId,
+          eventType: 'skipped_ticket_mode',
+          summary: 'Conversation is in manual/ticket mode — Flowise not called (settled at webhook)',
+          meta: { stage: 'webhook', channel: event.channel, inboxId: event.inboxId },
+        });
+        logger.info('Webhook settled without queueing: ticket mode', {
+          conversationId: event.conversationId,
+          messageId: event.messageId,
+        });
+        res.status(200).json({
+          ok: true,
+          skipped: true,
+          reason: 'ticket_mode',
+          conversationId: event.conversationId,
+        });
+        return;
+      }
+    }
+
     const jobId = await enqueue({ type: 'chatwoot_message', payload: { ...event } });
 
     await audit({
       conversationId: event.conversationId,
       messageId: event.messageId,
+      jobId,
       eventType: 'webhook_accepted',
       summary: `Queued job ${jobId} for conversation ${event.conversationId}`,
       meta: { channel: event.channel, inboxId: event.inboxId, attachments: event.attachmentCount },

+ 30 - 6
src/store/auditLog.ts

@@ -1,34 +1,58 @@
 import { db } from './db.js';
-import { redact } from '../logger.js';
+import { redact, redactString } from '../logger.js';
 
 export interface AuditInput {
   conversationId?: number | null;
   messageId?: string | null;
+  jobId?: string | null;
   eventType: string;
   summary: string;
   meta?: Record<string, unknown>;
 }
 
+export interface RecentEventsFilter {
+  limit?: number;
+  conversationId?: number;
+  eventType?: string;
+}
+
 /**
- * Append a non-secret audit row. Metadata always goes through the redactor, so
- * /admin/events can never become a secret- or PII-leak channel.
+ * Append a non-secret audit row. Both the summary and the metadata go through
+ * the redactor with PII logging forced off, so neither /admin/events nor the
+ * ops panel can become a secret- or PII-leak channel — regardless of what
+ * LOG_PII is set to for stdout logging.
  */
 export async function audit(input: AuditInput): Promise<void> {
   await db().auditEvent.create({
     data: {
       conversationId: input.conversationId ?? null,
       messageId: input.messageId ?? null,
+      jobId: input.jobId ?? null,
       eventType: input.eventType,
-      summary: input.summary.slice(0, 500),
+      summary: redactString(input.summary, false).slice(0, 500),
       metaJson: input.meta ? JSON.stringify(redact(input.meta, false)) : null,
     },
   });
 }
 
-export async function recentEvents(limit = 50, conversationId?: number) {
+export async function recentEvents(filter: RecentEventsFilter = {}) {
+  const { limit = 50, conversationId, eventType } = filter;
   return db().auditEvent.findMany({
-    where: conversationId ? { conversationId } : undefined,
+    where: {
+      ...(conversationId !== undefined ? { conversationId } : {}),
+      ...(eventType ? { eventType } : {}),
+    },
     orderBy: { createdAt: 'desc' },
     take: Math.min(Math.max(limit, 1), 200),
   });
 }
+
+/** Distinct event types present in the log — populates the panel's filter. */
+export async function knownEventTypes(): Promise<string[]> {
+  const rows = await db().auditEvent.findMany({
+    distinct: ['eventType'],
+    select: { eventType: true },
+    orderBy: { eventType: 'asc' },
+  });
+  return rows.map((r) => r.eventType);
+}

+ 248 - 0
tests/integration/opsPanel.test.ts

@@ -0,0 +1,248 @@
+import { after, before, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { prepareTestDatabase, startServer, type RunningServer } from '../helpers/testServer.js';
+import { applyTestConfig } from '../helpers/testConfig.js';
+
+const dbUrl = prepareTestDatabase('test-ops');
+
+const { createApp } = await import('../../src/http/app.js');
+const { db, disconnectDb } = await import('../../src/store/db.js');
+const { audit } = await import('../../src/store/auditLog.js');
+
+let running: RunningServer;
+
+const ADMIN = 'Bearer test-admin-token';
+const ADMIN_ENDPOINTS = [
+  '/admin/events',
+  '/admin/jobs',
+  '/admin/messages',
+  '/admin/tickets',
+  '/admin/meta',
+];
+
+before(async () => {
+  applyTestConfig({ DATABASE_URL: dbUrl });
+  running = await startServer(createApp());
+
+  await db().ticket.create({
+    data: { conversationId: 4242, ticketNumber: 'EKS-20260820-4242', reason: 'test handoff' },
+  });
+  await db().processedMessage.create({
+    data: { source: 'chatwoot', messageId: 'm-1', conversationId: 4242, status: 'skipped', reason: 'ticket_mode' },
+  });
+  await db().processedMessage.create({
+    data: { source: 'chatwoot', messageId: 'm-2', conversationId: 99, status: 'replied' },
+  });
+  await audit({ conversationId: 4242, messageId: 'm-1', jobId: 'job-1', eventType: 'webhook_accepted', summary: 'Queued job job-1' });
+  await audit({ conversationId: 4242, messageId: 'm-1', eventType: 'skipped_ticket_mode', summary: 'ticket mode' });
+  await audit({ conversationId: 99, messageId: 'm-2', eventType: 'reply_sent', summary: 'AI reply sent' });
+});
+
+after(async () => {
+  await running.close();
+  await disconnectDb();
+});
+
+async function get(path: string, auth?: string) {
+  const headers: Record<string, string> = {};
+  if (auth) headers.Authorization = auth;
+  const res = await fetch(`${running.baseUrl}${path}`, { headers, redirect: 'manual' });
+  const text = await res.text();
+  let json: Record<string, unknown> | null = null;
+  try {
+    json = JSON.parse(text) as Record<string, unknown>;
+  } catch {
+    json = null;
+  }
+  return { status: res.status, text, json, headers: res.headers };
+}
+
+// ───────────────────────────────────────────────────────────── panel shell
+
+test('GET /ops serves the panel shell without a token', async () => {
+  const res = await get('/ops');
+  assert.equal(res.status, 200);
+  assert.match(res.headers.get('content-type') ?? '', /text\/html/);
+  assert.match(res.text, /EKSRelay/);
+  assert.match(res.text, /ADMIN_TOKEN/); // the prompt label, not a value
+});
+
+test('the shell leaks no data and no credential', async () => {
+  const res = await get('/ops');
+  // Nothing from the seeded database may appear in the static HTML.
+  assert.ok(!res.text.includes('EKS-20260820-4242'), 'ticket number must not be inlined');
+  assert.ok(!res.text.includes('test-admin-token'), 'token must never be inlined');
+  assert.ok(!res.text.includes('test-shared-secret'));
+  assert.ok(!res.text.includes('test-chatwoot-token'));
+  assert.ok(!res.text.includes('4242'), 'no conversation data may be inlined');
+});
+
+test('the shell never puts the token in a query string', async () => {
+  const res = await get('/ops');
+  assert.ok(!/searchParams\.set\(\s*['"]token/i.test(res.text));
+  assert.ok(!/[?&]token=/.test(res.text));
+  // It must authenticate through the header instead.
+  assert.match(res.text, /Authorization['"]?\s*:\s*['"]Bearer/);
+});
+
+test('the shell sends hardening headers', async () => {
+  const res = await get('/ops');
+  assert.equal(res.headers.get('cache-control'), 'no-store');
+  assert.match(res.headers.get('x-robots-tag') ?? '', /noindex/);
+  const csp = res.headers.get('content-security-policy') ?? '';
+  assert.match(csp, /default-src 'none'/);
+  assert.match(csp, /connect-src 'self'/);
+  assert.match(csp, /frame-ancestors 'none'/);
+});
+
+test('the panel is not mounted inside the protected /admin namespace', async () => {
+  const res = await get('/admin/ui');
+  assert.equal(res.status, 401, '/admin/* must stay uniformly token-protected');
+});
+
+// ───────────────────────────────────────────────────────────────── auth
+
+test('every admin endpoint rejects a missing token', async () => {
+  for (const path of ADMIN_ENDPOINTS) {
+    const res = await get(path);
+    assert.equal(res.status, 401, `${path} must require a token`);
+    assert.equal(res.json?.code, 'UNAUTHORIZED');
+  }
+});
+
+test('every admin endpoint rejects a wrong token', async () => {
+  for (const path of ADMIN_ENDPOINTS) {
+    const res = await get(path, 'Bearer nope');
+    assert.equal(res.status, 401, `${path} must reject a wrong token`);
+  }
+});
+
+test('the relay shared secret does not open the admin API', async () => {
+  for (const path of ADMIN_ENDPOINTS) {
+    const res = await get(path, 'Bearer test-shared-secret');
+    assert.equal(res.status, 401, `${path} must not accept the tool secret`);
+  }
+});
+
+test('a token is not accepted from the query string', async () => {
+  const res = await get('/admin/events?token=test-admin-token');
+  assert.equal(res.status, 401);
+});
+
+test('admin endpoints answer with a valid token', async () => {
+  for (const path of ADMIN_ENDPOINTS) {
+    const res = await get(path, ADMIN);
+    assert.equal(res.status, 200, `${path} should answer`);
+    assert.equal(res.json?.ok, true);
+  }
+});
+
+// ─────────────────────────────────────────────────────────── read-only
+
+test('the admin API exposes no write verbs', async () => {
+  for (const path of ADMIN_ENDPOINTS) {
+    for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
+      const res = await fetch(`${running.baseUrl}${path}`, {
+        method,
+        headers: { Authorization: ADMIN, 'Content-Type': 'application/json' },
+        body: method === 'DELETE' ? undefined : '{}',
+      });
+      assert.equal(res.status, 404, `${method} ${path} must not exist`);
+    }
+  }
+});
+
+test('a read call does not mutate stored data', async () => {
+  const before = {
+    tickets: await db().ticket.count(),
+    messages: await db().processedMessage.count(),
+    events: await db().auditEvent.count(),
+  };
+  for (const path of ADMIN_ENDPOINTS) await get(path, ADMIN);
+  assert.deepEqual(
+    {
+      tickets: await db().ticket.count(),
+      messages: await db().processedMessage.count(),
+      events: await db().auditEvent.count(),
+    },
+    before,
+  );
+});
+
+// ──────────────────────────────────────────────────────────── filters
+
+test('events filter by conversationId', async () => {
+  const res = await get('/admin/events?conversationId=99', ADMIN);
+  const events = res.json?.events as { conversationId: number }[];
+  assert.ok(events.length > 0);
+  assert.ok(events.every((e) => e.conversationId === 99));
+});
+
+test('events filter by eventType', async () => {
+  const res = await get('/admin/events?eventType=reply_sent', ADMIN);
+  const events = res.json?.events as { eventType: string }[];
+  assert.ok(events.length > 0);
+  assert.ok(events.every((e) => e.eventType === 'reply_sent'));
+});
+
+test('events expose jobId as a column', async () => {
+  const res = await get('/admin/events?eventType=webhook_accepted', ADMIN);
+  const events = res.json?.events as { jobId: string | null }[];
+  assert.equal(events[0]?.jobId, 'job-1');
+});
+
+test('limit is honoured and clamped to a sane maximum', async () => {
+  const one = await get('/admin/events?limit=1', ADMIN);
+  assert.equal((one.json?.events as unknown[]).length, 1);
+
+  const clamped = await get('/admin/events?limit=100000', ADMIN);
+  assert.ok((clamped.json?.events as unknown[]).length <= 200);
+
+  const nonsense = await get('/admin/events?limit=abc', ADMIN);
+  assert.equal(nonsense.status, 200, 'a bad limit must not 500');
+});
+
+test('messages filter by status and conversationId', async () => {
+  const byStatus = await get('/admin/messages?status=replied', ADMIN);
+  const rows = byStatus.json?.messages as { status: string }[];
+  assert.ok(rows.length > 0);
+  assert.ok(rows.every((m) => m.status === 'replied'));
+
+  const byConv = await get('/admin/messages?conversationId=4242', ADMIN);
+  const convRows = byConv.json?.messages as { conversationId: number }[];
+  assert.ok(convRows.every((m) => m.conversationId === 4242));
+});
+
+test('jobs report queue statistics and a dead list', async () => {
+  const res = await get('/admin/jobs', ADMIN);
+  assert.ok(res.json?.stats);
+  assert.ok(Array.isArray(res.json?.jobs));
+  assert.ok(Array.isArray(res.json?.dead));
+});
+
+test('tickets carry the number, conversation, reason and derived activity', async () => {
+  const res = await get('/admin/tickets', ADMIN);
+  const tickets = res.json?.tickets as Record<string, unknown>[];
+  const t = tickets.find((x) => x.conversationId === 4242);
+  assert.ok(t, 'seeded ticket should be listed');
+  assert.equal(t.ticketNumber, 'EKS-20260820-4242');
+  assert.equal(t.reason, 'test handoff');
+  assert.ok(t.createdAt);
+  assert.equal(t.messagesSkippedSince, 1);
+  assert.ok(typeof t.lastEventType === 'string');
+});
+
+test('meta supplies the filter vocabulary without secrets', async () => {
+  const res = await get('/admin/meta', ADMIN);
+  const body = res.json as Record<string, unknown>;
+  assert.ok(Array.isArray(body.eventTypes));
+  assert.ok((body.eventTypes as string[]).includes('reply_sent'));
+  assert.ok(body.counts);
+
+  const serialized = JSON.stringify(body);
+  assert.ok(!serialized.includes('test-admin-token'));
+  assert.ok(!serialized.includes('test-shared-secret'));
+  assert.ok(!serialized.includes('test-chatwoot-token'));
+  assert.ok(!serialized.includes('test-flowise-key'));
+  assert.ok(!serialized.includes('ck_'));
+});

+ 202 - 0
tests/integration/ticketLogic.test.ts

@@ -0,0 +1,202 @@
+import { after, before, beforeEach, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { prepareTestDatabase } from '../helpers/testServer.js';
+import { applyTestConfig } from '../helpers/testConfig.js';
+import type { ChatwootClient } from '../../src/clients/chatwootClient.js';
+
+const dbUrl = prepareTestDatabase('test-ticket-logic');
+
+const { createTicket, hasLocalTicket } = await import('../../src/domain/ticketService.js');
+const { db, disconnectDb } = await import('../../src/store/db.js');
+
+/** Chatwoot double that records writes instead of performing them. */
+class FakeChatwoot {
+  labels: string[] = [];
+  attributeWrites: Record<string, unknown>[] = [];
+  unassignCalls = 0;
+  unassignSucceeds = true;
+  getCalls = 0;
+  conversation: Record<string, unknown> = { id: 500, labels: [], custom_attributes: {} };
+
+  async getConversation(): Promise<Record<string, unknown>> {
+    this.getCalls++;
+    return this.conversation;
+  }
+  async addLabel(_id: number, label: string): Promise<void> {
+    if (!this.labels.includes(label)) this.labels.push(label);
+  }
+  async setCustomAttributes(_id: number, attrs: Record<string, unknown>): Promise<void> {
+    this.attributeWrites.push(attrs);
+    const existing = (this.conversation.custom_attributes ?? {}) as Record<string, unknown>;
+    this.conversation.custom_attributes = { ...existing, ...attrs };
+  }
+  async unassignConversation(): Promise<boolean> {
+    this.unassignCalls++;
+    return this.unassignSucceeds;
+  }
+  async sendOutgoingMessage(): Promise<unknown> {
+    return {};
+  }
+}
+
+const as = (c: FakeChatwoot) => c as unknown as ChatwootClient;
+
+before(() => applyTestConfig({ DATABASE_URL: dbUrl }));
+beforeEach(async () => {
+  await db().auditEvent.deleteMany({});
+  await db().ticket.deleteMany({});
+});
+after(async () => {
+  await disconnectDb();
+});
+
+test('creating a ticket sets ticket_number, handoff and the ticket label', async () => {
+  const cw = new FakeChatwoot();
+  const result = await createTicket(500, 'klient prosi o człowieka', as(cw));
+
+  assert.equal(result.status, 'created');
+  assert.match(result.ticketNumber, /^EKS-\d{8}-500$/);
+  assert.ok(cw.labels.includes('ticket'));
+
+  const written = cw.attributeWrites[0] as Record<string, unknown>;
+  assert.equal(written.ticket_number, result.ticketNumber);
+  assert.equal(written.handoff, true);
+  assert.equal(written.handoff_reason, 'klient prosi o człowieka');
+});
+
+test('a repeated new_ticket returns the same number and creates no second row', async () => {
+  const cw = new FakeChatwoot();
+  const first = await createTicket(500, 'raz', as(cw));
+  const second = await createTicket(500, 'dwa', as(cw));
+
+  assert.equal(second.ticketNumber, first.ticketNumber);
+  assert.equal(second.status, 'existing');
+  assert.equal(await db().ticket.count({ where: { conversationId: 500 } }), 1);
+});
+
+test('concurrent new_ticket calls resolve to one ticket without throwing', async () => {
+  const cw = new FakeChatwoot();
+  const results = await Promise.all([
+    createTicket(500, 'a', as(cw)),
+    createTicket(500, 'b', as(cw)),
+    createTicket(500, 'c', as(cw)),
+  ]);
+
+  const numbers = new Set(results.map((r) => r.ticketNumber));
+  assert.equal(numbers.size, 1, 'all callers must see the same ticket number');
+  assert.equal(results.filter((r) => r.status === 'created').length, 1, 'exactly one creator');
+  assert.equal(await db().ticket.count({ where: { conversationId: 500 } }), 1);
+});
+
+test('an existing Chatwoot ticket_number is adopted, not overwritten', async () => {
+  const cw = new FakeChatwoot();
+  cw.conversation = {
+    id: 500,
+    labels: [],
+    custom_attributes: { ticket_number: 'EKS-20250101-500' },
+  };
+
+  const result = await createTicket(500, 'handoff', as(cw));
+
+  assert.equal(result.ticketNumber, 'EKS-20250101-500');
+  assert.equal(result.status, 'existing');
+  const row = await db().ticket.findUnique({ where: { conversationId: 500 } });
+  assert.equal(row?.ticketNumber, 'EKS-20250101-500');
+
+  const events = await db().auditEvent.findMany({ where: { eventType: 'ticket_adopted' } });
+  assert.equal(events.length, 1);
+});
+
+test('a ticket_number of "0" is not treated as an existing ticket', async () => {
+  const cw = new FakeChatwoot();
+  cw.conversation = { id: 500, labels: [], custom_attributes: { ticket_number: '0' } };
+
+  const result = await createTicket(500, 'handoff', as(cw));
+  assert.equal(result.status, 'created');
+  assert.match(result.ticketNumber, /^EKS-\d{8}-500$/);
+});
+
+test('unassign is skipped when the flag is off', async () => {
+  applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'false' });
+  const cw = new FakeChatwoot();
+  await createTicket(500, 'handoff', as(cw));
+  assert.equal(cw.unassignCalls, 0);
+
+  const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
+  const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
+  assert.equal(meta.unassignRequested, false);
+  assert.equal(meta.unassigned, null);
+});
+
+test('unassign runs when the flag is on and the real outcome is audited', async () => {
+  applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'true' });
+  const cw = new FakeChatwoot();
+  await createTicket(500, 'handoff', as(cw));
+  assert.equal(cw.unassignCalls, 1);
+
+  const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
+  const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
+  assert.equal(meta.unassignRequested, true);
+  assert.equal(meta.unassigned, true);
+  applyTestConfig({ DATABASE_URL: dbUrl });
+});
+
+test('a failed unassign is audited as failed, not as success', async () => {
+  applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'true' });
+  const cw = new FakeChatwoot();
+  cw.unassignSucceeds = false;
+  await createTicket(500, 'handoff', as(cw));
+
+  const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
+  const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
+  assert.equal(meta.unassigned, false, 'the audit must record effect, not intent');
+  applyTestConfig({ DATABASE_URL: dbUrl });
+});
+
+test('an adopted ticket is also unassigned when the flag is on', async () => {
+  applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'true' });
+  const cw = new FakeChatwoot();
+  cw.conversation = { id: 500, labels: [], custom_attributes: { ticket_number: 'EKS-20250101-500' } };
+
+  await createTicket(500, 'handoff', as(cw));
+
+  assert.equal(cw.unassignCalls, 1, 'adoption must reach the same Chatwoot state as creation');
+  assert.ok(cw.labels.includes('ticket'));
+  applyTestConfig({ DATABASE_URL: dbUrl });
+});
+
+test('a locally known ticket has its label re-asserted if Chatwoot lost it', async () => {
+  const cw = new FakeChatwoot();
+  await createTicket(500, 'handoff', as(cw));
+
+  // Somebody clears the label in the panel; the attributes stay.
+  cw.labels = [];
+  const again = await createTicket(500, 'handoff again', as(cw));
+
+  assert.equal(again.status, 'existing');
+  assert.ok(cw.labels.includes('ticket'), 'label must be restored');
+});
+
+test('an audit summary carrying an e-mail is redacted at write time', async () => {
+  const cw = new FakeChatwoot();
+  await createTicket(500, 'klient jan.kowalski@example.com prosi o kontakt', as(cw));
+
+  const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
+  assert.ok(!(event?.metaJson ?? '').includes('jan.kowalski@example.com'));
+
+  const { audit } = await import('../../src/store/auditLog.js');
+  await audit({
+    conversationId: 500,
+    eventType: 'test_event',
+    summary: 'kontakt: jan.kowalski@example.com',
+  });
+  const row = await db().auditEvent.findFirst({ where: { eventType: 'test_event' } });
+  assert.ok(!row?.summary.includes('jan.kowalski@example.com'));
+  assert.match(row?.summary ?? '', /REDACTED_EMAIL/);
+});
+
+test('hasLocalTicket reflects the stored state', async () => {
+  assert.equal(await hasLocalTicket(500), false);
+  await createTicket(500, 'handoff', new FakeChatwoot() as unknown as ChatwootClient);
+  assert.equal(await hasLocalTicket(500), true);
+});

+ 67 - 0
tests/integration/webhook.test.ts

@@ -127,3 +127,70 @@ test('garbage bodies are answered 200 without creating work', async () => {
   assert.equal(res.body.skipped, true);
   assert.equal(await db().job.count(), before);
 });
+
+test('an already-ticketed conversation is settled at the webhook without queueing', async () => {
+  const jobsBefore = await db().job.count();
+
+  const res = await post('/webhooks/chatwoot', {
+    ...incomingEmailMessage,
+    id: 99500,
+    conversation: {
+      ...incomingEmailMessage.conversation,
+      labels: ['ticket'],
+      custom_attributes: { ticket_number: 'EKS-20260820-1311', handoff: true },
+    },
+  });
+
+  assert.equal(res.status, 200);
+  assert.equal(res.body.skipped, true);
+  assert.equal(res.body.reason, 'ticket_mode');
+
+  assert.equal(await db().job.count(), jobsBefore, 'no job may be queued for a ticketed conversation');
+
+  const row = await db().processedMessage.findUnique({
+    where: { source_messageId: { source: 'chatwoot', messageId: '99500' } },
+  });
+  assert.equal(row?.status, 'skipped');
+  assert.equal(row?.reason, 'ticket_mode');
+
+  const events = await db().auditEvent.findMany({ where: { messageId: '99500' } });
+  assert.equal(events[0]?.eventType, 'skipped_ticket_mode');
+  assert.match(events[0]?.metaJson ?? '', /"stage":"webhook"/);
+});
+
+test('a ticketed conversation is still deduplicated on redelivery', async () => {
+  const payload = {
+    ...incomingEmailMessage,
+    id: 99501,
+    conversation: {
+      ...incomingEmailMessage.conversation,
+      labels: ['ticket'],
+      custom_attributes: {},
+    },
+  };
+  const first = await post('/webhooks/chatwoot', payload);
+  assert.equal(first.body.reason, 'ticket_mode');
+
+  const second = await post('/webhooks/chatwoot', payload);
+  assert.equal(second.status, 200);
+  assert.equal(second.body.duplicate, true);
+
+  const rows = await db().processedMessage.findMany({ where: { messageId: '99501' } });
+  assert.equal(rows.length, 1);
+});
+
+test('a payload lacking labels is still queued, so the worker can fetch them', async () => {
+  const res = await post('/webhooks/chatwoot', { ...noLabelsPayload, id: 99502 });
+  assert.equal(res.status, 202, 'the ticket state is unknown here — it must not be guessed');
+  assert.ok(res.body.jobId);
+});
+
+test('the queued audit event records the jobId in its own column', async () => {
+  const res = await post('/webhooks/chatwoot', { ...incomingEmailMessage, id: 99503 });
+  assert.equal(res.status, 202);
+
+  const event = await db().auditEvent.findFirst({
+    where: { messageId: '99503', eventType: 'webhook_accepted' },
+  });
+  assert.equal(event?.jobId, res.body.jobId);
+});