diff --git a/docs/INDEX.md b/docs/INDEX.md index a6822f5..02f3889 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,9 @@ # INDEX — 작업 기록 (완료 결과만) +- **2026-08-06** 오탐 피드백 (결정 a: 통계만, 자동 튜닝 없음): 이벤트 오탐 태그 + (`POST /events/{id}/false-positive`, ack 겸함, 최초 태거 보존) + `GET /api/fp-stats` + + 콘솔 오탐율 패널(30일, 30%+ 강조). PHPUnit 135 + Chrome 실렌더(태그→행 전환→패널 즉시 갱신). + - **2026-08-06** 대시보드 장치→카메라 트리 레일 + (device,camera) 쌍 카메라 필터 — `eaff176`. PHPUnit 119 통과 + Chrome 실렌더 검증(트리 상태·필터·접기·콘솔 무오류). - **2026-08-06** 감사 잔여 P2-6/7/8 종결 (ID 스왑 게이트 · sustained-prone FALLING · diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 8e25336..2959515 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -31,8 +31,9 @@ - 진입: `public/index.php` (라우트 전부 여기), 뷰: `src/View/{layout,events,login,zones,event_detail}.php` - 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` · `GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) · - `GET/POST /api/zones*` · `POST /login|/logout` · `GET /snap/{uuid}` · - `POST /events/{id}/ack` · 페이지 `/`, `/login`, `/zones`, `/events/{id}/view` + `GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` · + `GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그, + ack 겸함) · 페이지 `/`, `/login`, `/zones`, `/events/{id}/view` - `src/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF · `src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO) - 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) · diff --git a/server/migrations/2026-08-06-false-positive-tag.sql b/server/migrations/2026-08-06-false-positive-tag.sql new file mode 100644 index 0000000..b87220c --- /dev/null +++ b/server/migrations/2026-08-06-false-positive-tag.sql @@ -0,0 +1,6 @@ +-- Operator false-positive tag (오탐). Feeds per-camera/type FP-rate statistics only — +-- by product decision the tag never auto-tunes edge thresholds; a human reads the rate +-- and adjusts deliberately. Tagging also acknowledges the event (an FP is handled). +ALTER TABLE move_events + ADD COLUMN false_positive_by VARCHAR(190) NULL AFTER acknowledged_at, + ADD COLUMN false_positive_at BIGINT UNSIGNED NULL AFTER false_positive_by; diff --git a/server/public/assets/app.css b/server/public/assets/app.css index 9ed8d67..ea2f553 100644 --- a/server/public/assets/app.css +++ b/server/public/assets/app.css @@ -334,8 +334,25 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; } .chip { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit-ink); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; } -.ack-btn { margin-left:auto; flex:none; min-width:4.5rem; text-align:center; } -.ack-btn:disabled { color:var(--ink-3); background:none; border-color:transparent; box-shadow:none; font-family:var(--mono); font-size:.75rem; } +/* Actions column: [확인][오탐] while open; a single mono verdict line once resolved. */ +.row-actions { margin-left:auto; flex:none; display:flex; align-items:center; gap:.4rem; } +.ack-btn { flex:none; min-width:4.5rem; text-align:center; } +/* 오탐 is the rarer, corrective action — visually secondary to 확인 so it can't be + fat-fingered as the default. */ +.fp-btn { flex:none; padding:.32rem .55rem; font-size:.75rem; color:var(--ink-3); + background:none; border-color:transparent; box-shadow:none; } +.fp-btn:hover { color:var(--ink-2); border-color:var(--line); background:#fff; } +.ack-done, .fp-done { color:var(--ink-3); font-family:var(--mono); font-size:.75rem; white-space:nowrap; } +.fp-done { text-decoration:line-through; text-decoration-color:#c9d1da; } + +/* Rail: FP-rate report rows. */ +.fp-stats { display:flex; flex-direction:column; gap:.34rem; font-size:.74rem; } +.fp-stats.is-empty { color:var(--ink-3); } +.fp-row { display:flex; align-items:baseline; justify-content:space-between; gap:.6rem; min-width:0; } +.fp-what { color:var(--ink-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.fp-what .fp-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); margin-right:.15rem; } +.fp-rate { flex:none; font-family:var(--mono); color:var(--ink-3); } +.fp-rate.is-hot { color:var(--warn-ink); font-weight:700; } /* "new events arrived while paging" reload affordance — honest about a possible gap. */ .page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .6rem; padding:.45rem .7rem; diff --git a/server/public/assets/app.js b/server/public/assets/app.js index 71fe8f8..4434c3e 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -234,17 +234,38 @@ row.appendChild(meta); - var btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'ack-btn'; - if (isAcked) { - btn.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')'; - btn.disabled = true; + // Actions column. Resolution states are terminal and mutually exclusive in display: + // an FP tag wins (it is the more specific verdict — the event was also acknowledged + // server-side), then a plain acknowledgement, else the two action buttons. + var actions = document.createElement('div'); + actions.className = 'row-actions'; + var isFp = ev.false_positive_by !== null && ev.false_positive_by !== undefined && ev.false_positive_by !== ''; + if (isFp) { + var fpDone = document.createElement('span'); + fpDone.className = 'fp-done'; + fpDone.textContent = '오탐 (' + String(ev.false_positive_by) + ')'; + actions.appendChild(fpDone); + } else if (isAcked) { + var ackDone = document.createElement('span'); + ackDone.className = 'ack-done'; + ackDone.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')'; + actions.appendChild(ackDone); } else { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ack-btn'; btn.textContent = '확인'; btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); }); + actions.appendChild(btn); + var fpBtn = document.createElement('button'); + fpBtn.type = 'button'; + fpBtn.className = 'fp-btn'; + fpBtn.textContent = '오탐'; + fpBtn.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)'); + fpBtn.addEventListener('click', function () { markFalsePositive(ev.event_id, fpBtn, btn); }); + actions.appendChild(fpBtn); } - row.appendChild(btn); + row.appendChild(actions); return row; } @@ -269,16 +290,40 @@ }); } - function patchOlderAck(eventId) { + function patchOlderAck(eventId, alsoFp) { for (var i = 0; i < olderEvents.length; i++) { var ev = olderEvents[i]; if (ev && ev.event_id === eventId && !isAcked(ev)) { // New object, never mutate the existing one (keeps combinedEvents' dedup predictable). - olderEvents[i] = Object.assign({}, ev, { acknowledged_by: window.APP_USER || '확인' }); + var patch = { acknowledged_by: window.APP_USER || '확인' }; + if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐'; + olderEvents[i] = Object.assign({}, ev, patch); } } } + // The 오탐 tag: same terminality as an ack (the server also acknowledges), plus the event + // feeds the per-camera FP-rate panel. By product decision this is where the loop ENDS — + // the tag never auto-tunes edge thresholds; a human reads the rate and adjusts deliberately. + function markFalsePositive(eventId, fpBtn, ackBtn) { + fpBtn.disabled = true; + if (ackBtn) ackBtn.disabled = true; + fetch(B + '/events/' + encodeURIComponent(eventId) + '/false-positive', { + method: 'POST', + headers: { 'X-Csrf-Token': csrfToken || '' } + }) + .then(function (res) { + if (!res.ok) throw new Error('fp_failed:' + res.status); + patchOlderAck(eventId, true); + loadFpStats(); // the panel should reflect the tag immediately, not in 60s + return loadEvents(); + }) + .catch(function () { + fpBtn.disabled = false; + if (ackBtn) ackBtn.disabled = false; + }); + } + // Status strip: count UNACKNOWLEDGED events per severity from the same array the // list renders. Stat tiles (not a chart): small integers a desk operator scans. // Every tier tile is always drawn (zero -> muted) so the strip never reflows. @@ -600,6 +645,51 @@ }); } + // --- rail: FP-rate panel ---------------------------------------------------------- + // Per-(camera, type) false-positive rates over 30 days. Refreshed on its own slow cadence + // (60s) — tag volume changes slowly — plus immediately after the operator tags one. + var fpStatsRoot = document.getElementById('fp-stats-root'); + var FP_STATS_INTERVAL_MS = 60000; + var FP_STATS_SHOWN = 5; + + function renderFpStats(stats) { + if (!fpStatsRoot) return; + fpStatsRoot.textContent = ''; + if (!stats || stats.length === 0) { + fpStatsRoot.textContent = '오탐 태그가 없습니다.'; + fpStatsRoot.className = 'fp-stats is-empty'; + return; + } + fpStatsRoot.className = 'fp-stats'; + stats.slice(0, FP_STATS_SHOWN).forEach(function (s) { + var row = document.createElement('div'); + row.className = 'fp-row'; + var what = document.createElement('span'); + what.className = 'fp-what'; + var cam = document.createElement('span'); + cam.className = 'fp-cam'; + cam.textContent = s.camera_id; + what.appendChild(cam); + what.appendChild(document.createTextNode(' ' + (TYPE_LABELS[s.event_type] || s.event_type))); + row.appendChild(what); + var rate = document.createElement('span'); + var pct = Math.round((s.fp / Math.max(1, s.total)) * 100); + rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : ''); + rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')'; + // 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다). + row.appendChild(rate); + fpStatsRoot.appendChild(row); + }); + } + + function loadFpStats() { + if (!fpStatsRoot) return; + fetch(B + '/api/fp-stats?days=30', { headers: { Accept: 'application/json' } }) + .then(function (res) { return res.ok ? res.json() : null; }) + .then(function (data) { if (data) renderFpStats(data.stats); }) + .catch(function () { /* 패널은 보조 정보 — 실패 시 마지막 상태 유지 */ }); + } + document.addEventListener('click', unlockAudio); // Filter bar (client-side) + "더 보기" (server-side cursor). @@ -613,5 +703,7 @@ loadCsrfToken(); loadEvents(); loadDevices(); + loadFpStats(); setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS); + setInterval(loadFpStats, FP_STATS_INTERVAL_MS); })(); diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js index b8e37f6..a3f853b 100644 --- a/server/public/assets/detail.js +++ b/server/public/assets/detail.js @@ -98,6 +98,7 @@ addRow('발생 시각', textNode(formatTime(ev.occurred_at))); addRow('지속(초)', textNode(ev.duration_s)); addRow('확인', textNode(ev.acknowledged_by || '미확인')); + if (ev.false_positive_by) addRow('오탐', textNode(ev.false_positive_by + ' 태그')); if (!snapBox) return; snapBox.textContent = ''; diff --git a/server/public/index.php b/server/public/index.php index a479ec1..533bc83 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -46,7 +46,7 @@ use App\Ingest\IngestController; use App\Api\{ConfigController, HeartbeatController}; use App\Auth\{AuthController, CsrfController}; use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController, - DeviceStatusController, HeatmapController}; + DeviceStatusController, HeatmapController, FpStatsController}; // Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so // an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off @@ -96,6 +96,7 @@ $proxy = new SnapshotProxy($snaps, $events, $sess); $zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps); $deviceStatus = new DeviceStatusController($devices, $sess); $heatmap = new HeatmapController($events, $sess); +$fpStats = new FpStatsController($events, $sess); $render = function (string $view): Response { ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; @@ -113,8 +114,10 @@ $r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); $r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); $r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p)); +$r->add('POST', '/events/{id}/false-positive', fn(Request $q, array $p) => $detail->falsePositive($q, $p)); $r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p)); $r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p)); +$r->add('GET', '/api/fp-stats', fn(Request $q) => $fpStats->json($q)); // Background frame for the zone editor: the newest snapshot this camera produced. $r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p)); $r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q)); diff --git a/server/schema.sql b/server/schema.sql index 5d15919..3a4743a 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -130,6 +130,8 @@ CREATE TABLE move_events ( spatial_y DOUBLE NULL, -- heatmap raw material; NULL for legacy/deviceless events acknowledged_by VARCHAR(190) NULL, acknowledged_at BIGINT UNSIGNED NULL, + false_positive_by VARCHAR(190) NULL, -- operator's 오탐 tag (per-camera FP-rate stats; no auto-tuning) + false_positive_at BIGINT UNSIGNED NULL, received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8) PRIMARY KEY (event_id), -- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side diff --git a/server/src/Dashboard/EventDetailController.php b/server/src/Dashboard/EventDetailController.php index 344fff3..dce8805 100644 --- a/server/src/Dashboard/EventDetailController.php +++ b/server/src/Dashboard/EventDetailController.php @@ -48,4 +48,24 @@ final class EventDetailController $this->audit->append($user, 'acknowledge', "event:$id", [], []); return Response::json(['ok' => true], 200); } + + /** POST /events/{id}/false-positive — operator's 오탐 tag. Also acknowledges (an FP is a + * handled event; leaving it unacknowledged would keep it alarming). Feeds FP-rate stats + * only — by product decision it never auto-tunes edge thresholds. */ + public function falsePositive(Request $req, array $params): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + // Same permission as acknowledge: tagging FP is the same "I handled this" judgement call. + if (!Rbac::can((string)$this->sess->get('role'), 'acknowledge')) return Response::json(['error' => 'forbidden'], 403); + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } + $id = (int)($params['id'] ?? 0); + $event = $this->events->find($id); + if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404); + $user = (string)$this->sess->get('username'); + if (!$this->events->markFalsePositive($id, $user)) return Response::json(['error' => 'not_found'], 404); + $this->audit->append($user, 'false_positive', "event:$id", [], []); + return Response::json(['ok' => true], 200); + } } diff --git a/server/src/Dashboard/FpStatsController.php b/server/src/Dashboard/FpStatsController.php new file mode 100644 index 0000000..3fc40dc --- /dev/null +++ b/server/src/Dashboard/FpStatsController.php @@ -0,0 +1,39 @@ + {stats: [{camera_id, event_type, total, fp}], days} + * + * Per-(camera, event type) false-positive rates for the console's FP panel. This is the + * whole feedback loop by design: a human reads the rate and re-tunes thresholds + * deliberately — the tag never adjusts edge sensitivity on its own. + */ +final class FpStatsController +{ + private const DEFAULT_DAYS = 30; + private const MAX_DAYS = 90; + + public function __construct(private EventRepository $events, private SessionStore $sess) {} + + public function json(Request $req): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + $days = self::clampDays($req->query('days')); + // Fail-closed site scoping, mirroring the event list. + $scope = $this->sess->get('site_scope'); + $site = is_string($scope) ? $scope : null; + $stats = $this->events->fpStats((float)(time() - $days * 86400), $site); + return Response::json(['stats' => $stats, 'days' => $days], 200); + } + + private static function clampDays(mixed $raw): int + { + if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS; + return max(1, min(self::MAX_DAYS, (int)$raw)); + } +} diff --git a/server/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php index 20846f4..56ae28a 100644 --- a/server/src/Repository/EventRepository.php +++ b/server/src/Repository/EventRepository.php @@ -26,4 +26,17 @@ interface EventRepository * site-scope filter as page(). Rows: {x, y, severity, event_type, occurred_at}. */ public function heatmapPoints(string $cameraId, float $sinceOccurredAt, ?string $site, int $limit): array; + /** + * Tag an event as a false positive AND acknowledge it (an FP is a handled event — + * leaving it unacknowledged would keep it alarming). Idempotent on the FP flag: + * the FIRST tagger is preserved. Returns false only when the event does not exist. + */ + public function markFalsePositive(int $id, string $user): bool; + /** + * FP-rate aggregation for the stats panel: per (camera_id, event_type) totals and + * false-positive counts since $sinceOccurredAt, only groups with at least one FP. + * $site applies the same fail-closed scope filter as page(). + * Rows: {camera_id, event_type, total, fp}. + */ + public function fpStats(float $sinceOccurredAt, ?string $site): array; } diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php index 3b3cd53..2876810 100644 --- a/server/src/Repository/Pdo/PdoEventRepository.php +++ b/server/src/Repository/Pdo/PdoEventRepository.php @@ -9,7 +9,8 @@ final class PdoEventRepository implements EventRepository { private const SELECT_COLUMNS = 'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id, - occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at'; + occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at, + false_positive_by, false_positive_at'; public function __construct(private PDO $pdo) {} @@ -160,6 +161,52 @@ final class PdoEventRepository implements EventRepository ], $st->fetchAll()); } + public function markFalsePositive(int $id, string $user): bool + { + // Same matched-vs-changed discipline as acknowledge(): existence checked separately, + // because a no-op UPDATE reports 0 affected rows on MySQL's default PDO settings. + $exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1'); + $exists->execute([$id]); + if ($exists->fetchColumn() === false) return false; + $now = time(); + // COALESCE keeps the FIRST tagger/acknowledger — a second tag must not rewrite who + // made the original call (it is an audit-relevant judgement). + $st = $this->pdo->prepare( + 'UPDATE move_events + SET false_positive_by = COALESCE(false_positive_by, ?), + false_positive_at = COALESCE(false_positive_at, ?), + acknowledged_by = COALESCE(acknowledged_by, ?), + acknowledged_at = COALESCE(acknowledged_at, ?) + WHERE event_id = ?' + ); + $st->execute([$user, $now, $user, $now, $id]); + return true; + } + + public function fpStats(float $sinceOccurredAt, ?string $site): array + { + $sql = 'SELECT camera_id, event_type, COUNT(*) AS total, + SUM(false_positive_by IS NOT NULL) AS fp + FROM move_events + WHERE occurred_at >= ?'; + $binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]]; + if ($site !== null) { + $sql .= ' AND site = ?'; + $binds[] = [$site, PDO::PARAM_STR]; + } + $sql .= ' GROUP BY camera_id, event_type HAVING fp > 0 ORDER BY fp DESC'; + $st = $this->pdo->prepare($sql); + $pos = 1; + foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type); + $st->execute(); + return array_map(static fn ($r) => [ + 'camera_id' => $r['camera_id'], + 'event_type' => $r['event_type'], + 'total' => (int)$r['total'], + 'fp' => (int)$r['fp'], + ], $st->fetchAll()); + } + private static function mapRow(array $row): array { return [ @@ -177,6 +224,8 @@ final class PdoEventRepository implements EventRepository 'metrics' => json_decode((string)($row['metrics_json'] ?? '[]'), true) ?? [], 'spatial_x' => $row['spatial_x'] !== null ? (float)$row['spatial_x'] : null, 'spatial_y' => $row['spatial_y'] !== null ? (float)$row['spatial_y'] : null, + 'false_positive_by' => $row['false_positive_by'] ?? null, + 'false_positive_at' => ($row['false_positive_at'] ?? null) !== null ? (int)$row['false_positive_at'] : null, 'acknowledged_by' => $row['acknowledged_by'], 'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null, ]; diff --git a/server/src/View/events.php b/server/src/View/events.php index cb2552a..f9af50b 100644 --- a/server/src/View/events.php +++ b/server/src/View/events.php @@ -29,6 +29,12 @@ render_header('이벤트'); +
+

오탐율 · 30일

+ +
집계 중…
+
diff --git a/server/tests/DashboardTest.php b/server/tests/DashboardTest.php index cb66f09..46485da 100644 --- a/server/tests/DashboardTest.php +++ b/server/tests/DashboardTest.php @@ -82,6 +82,33 @@ final class DashboardTest extends TestCase // acknowledge did not proceed $this->assertNull($repo->find($id)['acknowledged_by']); } + public function testFalsePositiveTagAcknowledgesAndKeepsFirstTagger(): void { + $repo = new InMemoryEventRepository(); + $id = $repo->insert(['severity' => 'warning', 'event_type' => 'zone_warning']); + $req = new Request('POST', "/events/$id/false-positive", ['X-Csrf-Token' => 'tok'], '', [], []); + // viewer denied (same 'acknowledge' permission as ack) + $cv = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('viewer')); + $this->assertSame(403, $cv->falsePositive($req, ['id' => (string)$id])->status()); + // operator tags: FP set AND acknowledged (an FP is a handled event) + $co = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator')); + $this->assertSame(200, $co->falsePositive($req, ['id' => (string)$id])->status()); + $row = $repo->find($id); + $this->assertSame('u', $row['false_positive_by']); + $this->assertSame('u', $row['acknowledged_by']); + // a second tag by another user must NOT rewrite the original judgement + $s2 = $this->authed('operator'); $s2->set('username', 'u2'); + $c2 = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $s2); + $this->assertSame(200, $c2->falsePositive($req, ['id' => (string)$id])->status()); + $this->assertSame('u', $repo->find($id)['false_positive_by']); + } + public function testFalsePositiveRejectsBadCsrf(): void { + $repo = new InMemoryEventRepository(); + $id = $repo->insert(['severity' => 'warning', 'event_type' => 'zone_warning']); + $c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator')); + $wrong = new Request('POST', "/events/$id/false-positive", ['X-Csrf-Token' => 'nope'], '', [], []); + $this->assertSame(403, $c->falsePositive($wrong, ['id' => (string)$id])->status()); + $this->assertNull($repo->find($id)['false_positive_by'] ?? null); + } public function testSnapshotProxyAuthAnd404(): void { $snaps = new InMemorySnapshotRepository(); $events = new InMemoryEventRepository(); diff --git a/server/tests/FpStatsTest.php b/server/tests/FpStatsTest.php new file mode 100644 index 0000000..d37df54 --- /dev/null +++ b/server/tests/FpStatsTest.php @@ -0,0 +1,62 @@ +set('user_id', 1); + if ($site !== null) $s->set('site_scope', $site); + return $s; + } + + private function repo(): InMemoryEventRepository + { + $r = new InMemoryEventRepository(); + $now = (float)time(); + // cam-1 zone_warning: 3 events, 2 FP-tagged + $a = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]); + $b = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]); + $r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]); + $r->markFalsePositive($a, 'u'); + $r->markFalsePositive($b, 'u'); + // cam-2 fall on another site: 1 event, 1 FP + $c = $r->insert(['camera_id' => 'cam-2', 'event_type' => 'fall', 'severity' => 'high', 'site' => 'b', 'occurred_at' => $now]); + $r->markFalsePositive($c, 'u'); + // cam-3: events but no FP tags -> not in the report + $r->insert(['camera_id' => 'cam-3', 'event_type' => 'fall', 'severity' => 'high', 'site' => 'a', 'occurred_at' => $now]); + return $r; + } + + public function testRequiresLogin(): void + { + $c = new FpStatsController($this->repo(), new ArraySessionStore()); + $this->assertSame(401, $c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->status()); + } + + public function testAggregatesPerCameraAndType(): void + { + $c = new FpStatsController($this->repo(), $this->sess()); + $data = json_decode($c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->body(), true); + $this->assertSame(30, $data['days']); + $this->assertCount(2, $data['stats']); // cam-3 (no FP) excluded + $this->assertSame(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'total' => 3, 'fp' => 2], + $data['stats'][0]); // sorted by fp desc + } + + public function testSiteScopeFiltersStats(): void + { + $c = new FpStatsController($this->repo(), $this->sess('a')); + $data = json_decode($c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->body(), true); + $this->assertCount(1, $data['stats']); + $this->assertSame('cam-1', $data['stats'][0]['camera_id']); + } +} diff --git a/server/tests/PdoIntegrationTest.php b/server/tests/PdoIntegrationTest.php index 826965b..0fdd128 100644 --- a/server/tests/PdoIntegrationTest.php +++ b/server/tests/PdoIntegrationTest.php @@ -170,6 +170,8 @@ final class PdoIntegrationTest extends TestCase spatial_y REAL, acknowledged_by TEXT, acknowledged_at INTEGER, + false_positive_by TEXT, + false_positive_at INTEGER, received_at INTEGER NOT NULL ); diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php index 5b0f7f6..530fc94 100644 --- a/server/tests/Support/InMemoryRepositories.php +++ b/server/tests/Support/InMemoryRepositories.php @@ -61,6 +61,29 @@ final class InMemoryEventRepository implements EventRepository { } public function find(int $id): ?array { return $this->rows[$id] ?? null; } public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; } + public function markFalsePositive(int $id, string $user): bool { + if (!isset($this->rows[$id])) return false; + $r = $this->rows[$id]; + $r['false_positive_by'] = $r['false_positive_by'] ?? $user; + $r['false_positive_at'] = $r['false_positive_at'] ?? time(); + $r['acknowledged_by'] = ($r['acknowledged_by'] ?? null) !== null ? $r['acknowledged_by'] : $user; + $this->rows[$id] = $r; + return true; + } + public function fpStats(float $since, ?string $site): array { + $groups = []; + foreach ($this->rows as $r) { + if (($r['occurred_at'] ?? 0) < $since) continue; + if ($site !== null && ($r['site'] ?? null) !== $site) continue; + $key = ($r['camera_id'] ?? '') . '|' . ($r['event_type'] ?? ''); + $groups[$key] = $groups[$key] ?? ['camera_id' => $r['camera_id'], 'event_type' => $r['event_type'], 'total' => 0, 'fp' => 0]; + $groups[$key]['total']++; + if (($r['false_positive_by'] ?? null) !== null) $groups[$key]['fp']++; + } + $out = array_values(array_filter($groups, fn($g) => $g['fp'] > 0)); + usort($out, fn($a, $b) => $b['fp'] <=> $a['fp']); + return $out; + } public function heatmapPoints(string $cameraId, float $since, ?string $site, int $limit): array { $r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId && ($x['occurred_at'] ?? 0) >= $since