move_detect/server/src/Repository/Pdo/PdoEventRepository.php
유창욱 e00ad69aa3 feat(safety,ux): 통보·억제·증거 체계 신설 + UI/UX 감사 21건 반영
UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업.
두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다.

━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

동선 — 확인(ack) 경로가 위험했다
- 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를
  뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이
  밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서
  '확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리
  (전환해도 행끼리 상대 순서는 절대 안 바뀐다).
- 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라
  무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로.
- 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다.
- 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고,
  플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다.
- 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다.
- 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이
  통째로 꺼지는 경로다.

전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다
- KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다.
  KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의.
- 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장
  (그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다).
- 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리
  시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를
  넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다.
- 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로.

시각 — 화면마다 완성도가 달랐다
- 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고,
  납품 심사에서 "덜 만든 티"는 정확히 거기서 났다.
- 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다.
- 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선.
  캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다.

━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━

통보 — 이 제품의 정의에 해당하는 구멍
  지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에
  넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다.
- 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라
  어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가
  없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다.
- 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가
  아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다.
- 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등).
  '아무도 보지 않았다'는 사실 자체를 사건으로 만든다.
- 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간
  인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다.

조용한 실패 제거
- 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을
  전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속
  초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다.
- 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다.

끊겨 있던 배선 2건
- thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고
  /api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도
  장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py).
- 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수
  없었다. 지표는 있는데 그것을 켜는 사건이 없었다.

관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다)
- 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은
  엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다.
  목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다.
- 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고
  통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만
  의도적으로 withheld됐다'를 볼 수 있어야 한다.
- 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반).

증거·규정
- 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수
  없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시
  exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이
  매번 빨간불을 켜면 아무도 이 도구를 안 본다.
- 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 +
  조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다.
- 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만).

운영
- 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter),
  모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php),
  백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php).
- tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다.

미착수 4건 (모델·하드웨어가 선행 조건)
- 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다.
  감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다
  (edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록
  명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다.
- PLC 접점 협착 게이팅도 같은 이유로 인터페이스만.

검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59).
Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→
행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개)
재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0,
상태 변경 핸들러 전부 CSRF+RBAC.
2026-08-18 07:51:23 +09:00

465 lines
21 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\EventRepository;
use PDO;
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,
false_positive_by, false_positive_at';
public function __construct(private PDO $pdo) {}
public function insert(array $event): int
{
$st = $this->pdo->prepare(
'INSERT INTO move_events
(event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
occurred_at, duration_s, metrics_json, spatial_x, spatial_y, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$st->execute([
$event['event_uuid'] ?? null, // NULL when the edge sent no idempotency key (legacy)
(string)$event['device_id'],
$event['site'] ?? null, // denormalized from move_devices.site by IngestController
(string)$event['camera_id'],
(string)$event['event_type'],
(string)$event['severity'],
$event['track_id'] ?? null,
$event['zone_id'] ?? null,
(float)$event['occurred_at'],
(float)($event['duration_s'] ?? 0.0),
json_encode($event['metrics'] ?? [], JSON_THROW_ON_ERROR),
$event['spatial_x'] ?? null,
$event['spatial_y'] ?? null,
time(),
]);
return (int)$this->pdo->lastInsertId();
}
public function idByUuid(string $uuid): ?int
{
$st = $this->pdo->prepare('SELECT event_id FROM move_events WHERE event_uuid = ? LIMIT 1');
$st->execute([$uuid]);
$id = $st->fetchColumn();
return $id === false ? null : (int)$id;
}
public function recent(int $limit, ?string $severity): array
{
if ($severity !== null) {
$st = $this->pdo->prepare(
'SELECT ' . self::SELECT_COLUMNS . '
FROM move_events WHERE severity = ?
ORDER BY occurred_at DESC, event_id DESC LIMIT ?'
);
$st->bindValue(1, $severity, PDO::PARAM_STR);
$st->bindValue(2, $limit, PDO::PARAM_INT);
} else {
$st = $this->pdo->prepare(
'SELECT ' . self::SELECT_COLUMNS . '
FROM move_events
ORDER BY occurred_at DESC, event_id DESC LIMIT ?'
);
$st->bindValue(1, $limit, PDO::PARAM_INT);
}
$st->execute();
return array_map(self::mapRow(...), $st->fetchAll());
}
public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array
{
// Bind list as [value, PDO type] pairs, positional in SQL order.
$where = [];
$binds = [];
if ($severity !== null) {
$where[] = 'severity = ?';
$binds[] = [$severity, PDO::PARAM_STR];
}
if ($site !== null) {
// Site-scope access control: a scoped operator sees only their site's events.
$where[] = 'site = ?';
$binds[] = [$site, PDO::PARAM_STR];
}
// Keyset predicate: strictly "older" than the client's last-seen (occurred_at, event_id)
// on the (occurred_at DESC, event_id DESC) order. occurred_at arrives as its exact numeric
// STRING (never round-tripped through a PHP float) and is bound as a string, so MySQL
// parses it to the same DOUBLE the column holds — the page-edge boundary stays exact.
if ($beforeTs !== null && $beforeId !== null) {
$where[] = '(occurred_at < ? OR (occurred_at = ? AND event_id < ?))';
$binds[] = [$beforeTs, PDO::PARAM_STR];
$binds[] = [$beforeTs, PDO::PARAM_STR];
$binds[] = [$beforeId, PDO::PARAM_INT];
}
$sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events';
if ($where) $sql .= ' WHERE ' . implode(' AND ', $where);
$sql .= ' ORDER BY occurred_at DESC, event_id DESC LIMIT ?';
$binds[] = [$limit, PDO::PARAM_INT];
$st = $this->pdo->prepare($sql);
$pos = 1;
foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type);
$st->execute();
return array_map(self::mapRow(...), $st->fetchAll());
}
public function find(int $id): ?array
{
$st = $this->pdo->prepare('SELECT ' . self::SELECT_COLUMNS . ' FROM move_events WHERE event_id = ?');
$st->execute([$id]);
$row = $st->fetch();
return $row === false ? null : self::mapRow($row);
}
public function acknowledge(int $id, string $user): bool
{
// Existence is checked separately (rather than trusting UPDATE's
// rowCount()) because MySQL's PDO driver, by default, reports
// "changed rows" rather than "matched rows": re-acknowledging an
// already-acknowledged event by the same user would otherwise report
// 0 affected rows and this method would wrongly return false.
$exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1');
$exists->execute([$id]);
if ($exists->fetchColumn() === false) {
return false;
}
$st = $this->pdo->prepare(
'UPDATE move_events SET acknowledged_by = ?, acknowledged_at = ? WHERE event_id = ?'
);
$st->execute([$user, time(), $id]);
return true;
}
public function heatmapPoints(string $cameraId, float $sinceOccurredAt, ?string $site, int $limit): array
{
$sql = 'SELECT spatial_x, spatial_y, severity, event_type, occurred_at
FROM move_events
WHERE camera_id = ? AND occurred_at >= ?
AND spatial_x IS NOT NULL AND spatial_y IS NOT NULL';
$binds = [[$cameraId, PDO::PARAM_STR], [sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
if ($site !== null) {
// Same fail-closed site scoping as page(): an empty-string scope matches nothing.
$sql .= ' AND site = ?';
$binds[] = [$site, PDO::PARAM_STR];
}
$sql .= ' ORDER BY occurred_at DESC LIMIT ?';
$binds[] = [$limit, PDO::PARAM_INT];
$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) => [
'x' => (float)$r['spatial_x'],
'y' => (float)$r['spatial_y'],
'severity' => $r['severity'],
'event_type' => $r['event_type'],
'occurred_at' => (float)$r['occurred_at'],
], $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());
}
public function reportSummary(float $sinceOccurredAt, ?string $site, int $topCameras,
?float $untilOccurredAt = null): array
{
$sinceStr = sprintf('%.6f', $sinceOccurredAt);
// Optional upper bound so the SAME aggregation can run over the preceding window
// (period-over-period trend) without duplicating any of these queries.
$until = $untilOccurredAt !== null ? ' AND occurred_at < ?' : '';
$untilBind = $untilOccurredAt !== null ? [sprintf('%.6f', $untilOccurredAt)] : [];
$scope = $site !== null ? ' AND site = ?' : '';
$scopeBind = $site !== null ? [$site] : [];
$byType = $this->pdo->prepare(
"SELECT event_type, severity, COUNT(*) AS count,
SUM(acknowledged_by IS NULL) AS unacked,
SUM(false_positive_by IS NOT NULL) AS fp
FROM move_events WHERE occurred_at >= ?$until$scope
GROUP BY event_type, severity"
);
$byType->execute([$sinceStr, ...$untilBind, ...$scopeBind]);
$byCamera = $this->pdo->prepare(
"SELECT camera_id, COUNT(*) AS count,
SUM(severity IN ('critical','high')) AS critical_high
FROM move_events WHERE occurred_at >= ?$until$scope
GROUP BY camera_id ORDER BY critical_high DESC, count DESC LIMIT ?"
);
$pos = 1;
$byCamera->bindValue($pos++, $sinceStr, PDO::PARAM_STR);
foreach ($untilBind as $v) $byCamera->bindValue($pos++, $v, PDO::PARAM_STR);
foreach ($scopeBind as $v) $byCamera->bindValue($pos++, $v, PDO::PARAM_STR);
$byCamera->bindValue($pos, $topCameras, PDO::PARAM_INT);
$byCamera->execute();
// Response time counts only real emergencies someone actually confirmed: critical/high,
// acknowledged, not FP-tagged (dismissing a false alarm is not an emergency response).
// acknowledged_at >= occurred_at guards clock-skewed rows from producing negative TTA.
$tta = $this->pdo->prepare(
"SELECT COUNT(*) AS acked,
AVG(acknowledged_at - occurred_at) AS avg_s,
MAX(acknowledged_at - occurred_at) AS max_s
FROM move_events
WHERE occurred_at >= ?$until$scope AND severity IN ('critical','high')
AND acknowledged_at IS NOT NULL AND false_positive_by IS NULL
AND acknowledged_at >= occurred_at"
);
$tta->execute([$sinceStr, ...$untilBind, ...$scopeBind]);
$ttaRow = $tta->fetch() ?: ['acked' => 0, 'avg_s' => null, 'max_s' => null];
$ergo = $this->pdo->prepare(
"SELECT metrics_json FROM move_events
WHERE occurred_at >= ?$until$scope AND event_type = 'ergo_report'
ORDER BY occurred_at DESC LIMIT 2000"
);
$ergo->execute([$sinceStr, ...$untilBind, ...$scopeBind]);
return [
'by_type' => array_map(static fn ($r) => [
'event_type' => $r['event_type'], 'severity' => $r['severity'],
'count' => (int)$r['count'], 'unacked' => (int)$r['unacked'], 'fp' => (int)$r['fp'],
], $byType->fetchAll()),
'by_camera' => array_map(static fn ($r) => [
'camera_id' => $r['camera_id'], 'count' => (int)$r['count'],
'critical_high' => (int)$r['critical_high'],
], $byCamera->fetchAll()),
'tta' => [
'acked' => (int)$ttaRow['acked'],
'avg_s' => $ttaRow['avg_s'] !== null ? (float)$ttaRow['avg_s'] : null,
'max_s' => $ttaRow['max_s'] !== null ? (float)$ttaRow['max_s'] : null,
],
'ergo_metrics' => array_map(
static fn ($r) => json_decode((string)$r['metrics_json'], true) ?: [],
$ergo->fetchAll()
),
];
}
public function occurrenceTimes(float $sinceOccurredAt, ?string $site, int $limit): array
{
$sql = "SELECT occurred_at, event_type FROM move_events
WHERE occurred_at >= ? AND event_type <> 'ergo_report'";
$binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
if ($site !== null) {
$sql .= ' AND site = ?';
$binds[] = [$site, PDO::PARAM_STR];
}
$sql .= ' ORDER BY occurred_at DESC LIMIT ?';
$binds[] = [$limit, PDO::PARAM_INT];
$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) => [
'occurred_at' => (float)$r['occurred_at'],
'event_type' => $r['event_type'],
], $st->fetchAll());
}
public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array
{
$sql = 'SELECT ' . self::SELECT_COLUMNS . ' 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 .= ' ORDER BY occurred_at DESC, event_id DESC LIMIT ?';
$binds[] = [$limit, PDO::PARAM_INT];
$st = $this->pdo->prepare($sql);
$pos = 1;
foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type);
$st->execute();
return array_map(self::mapRow(...), $st->fetchAll());
}
public function forNotification(float $sinceOccurredAt, int $limit): array
{
// No site filter: the dispatcher is a server-side job, not a logged-in operator, and it
// must be able to notify every site the installation serves. Channel rows carry their
// own site, which is where the scoping actually belongs.
$sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events
WHERE occurred_at >= ? ORDER BY occurred_at ASC, event_id ASC LIMIT ?';
$st = $this->pdo->prepare($sql);
$st->bindValue(1, sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR);
$st->bindValue(2, $limit, PDO::PARAM_INT);
$st->execute();
return array_map(self::mapRow(...), $st->fetchAll());
}
public function siteSummary(float $sinceOccurredAt, ?float $untilOccurredAt = null): array
{
// COUNT(DISTINCT camera_id) alongside the totals: "three sites, one of which is watching
// with a single camera" is a different picture from "three comparable sites", and the
// head-office view is exactly where that distinction gets lost.
$sql = "SELECT site,
COUNT(*) AS count,
SUM(CASE WHEN severity IN ('critical','high') THEN 1 ELSE 0 END) AS critical_high,
SUM(CASE WHEN acknowledged_by IS NULL THEN 1 ELSE 0 END) AS unacked,
COUNT(DISTINCT camera_id) AS cameras
FROM move_events
WHERE occurred_at >= ? AND event_type <> 'ergo_report'";
$binds = [sprintf('%.6f', $sinceOccurredAt)];
if ($untilOccurredAt !== null) {
$sql .= ' AND occurred_at < ?';
$binds[] = sprintf('%.6f', $untilOccurredAt);
}
$sql .= ' GROUP BY site ORDER BY critical_high DESC, count DESC';
$st = $this->pdo->prepare($sql);
$st->execute($binds);
return array_map(static fn (array $r): array => [
'site' => $r['site'] !== null ? (string)$r['site'] : null,
'count' => (int)$r['count'],
'critical_high' => (int)$r['critical_high'],
'unacked' => (int)$r['unacked'],
'cameras' => (int)$r['cameras'],
], $st->fetchAll());
}
public function windowForCamera(string $cameraId, float $from, float $to, ?string $site, int $limit): array
{
$sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events
WHERE camera_id = ? AND occurred_at >= ? AND occurred_at < ?';
$binds = [[$cameraId, PDO::PARAM_STR], [sprintf('%.6f', $from), PDO::PARAM_STR],
[sprintf('%.6f', $to), PDO::PARAM_STR]];
if ($site !== null) {
$sql .= ' AND site = ?';
$binds[] = [$site, PDO::PARAM_STR];
}
$sql .= ' ORDER BY occurred_at ASC, event_id ASC LIMIT ?';
$binds[] = [$limit, PDO::PARAM_INT];
$st = $this->pdo->prepare($sql);
$pos = 1;
foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type);
$st->execute();
return array_map(self::mapRow(...), $st->fetchAll());
}
public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int
{
$sql = "SELECT COUNT(*) FROM move_events
WHERE camera_id = ? AND occurred_at >= ? AND occurred_at < ?
AND event_type <> 'ergo_report'";
$binds = [$cameraId, sprintf('%.6f', $from), sprintf('%.6f', $to)];
if ($site !== null) {
$sql .= ' AND site = ?';
$binds[] = $site;
}
$st = $this->pdo->prepare($sql);
$st->execute($binds);
return (int)$st->fetchColumn();
}
public function purgeOlderThan(float $cutoff, int $limit = 500): int
{
// Bounded batch, same shape as the snapshot purge: a shared-hosting request must not
// stall deleting a year of rows. ON DELETE CASCADE on move_event_snapshots would drop
// snapshot ROWS with the event, so purge only what the snapshot purge has already
// emptied of files — cutoff is expected to be far older than the image retention window.
$sel = $this->pdo->prepare(
'SELECT event_id FROM move_events WHERE occurred_at < ? ORDER BY event_id LIMIT ?');
$sel->bindValue(1, sprintf('%.6f', $cutoff), PDO::PARAM_STR);
$sel->bindValue(2, $limit, PDO::PARAM_INT);
$sel->execute();
$ids = array_map('intval', $sel->fetchAll(PDO::FETCH_COLUMN));
if (!$ids) {
return 0;
}
$in = implode(',', array_fill(0, count($ids), '?'));
$this->pdo->prepare("DELETE FROM move_events WHERE event_id IN ($in)")->execute($ids);
return count($ids);
}
public function cameraExists(string $cameraId): bool
{
$st = $this->pdo->prepare('SELECT 1 FROM move_events WHERE camera_id = ? LIMIT 1');
$st->execute([$cameraId]);
return $st->fetchColumn() !== false;
}
public function siteForCamera(string $cameraId): ?string
{
// Newest event wins: if a camera is re-homed to another site, later actions belong to
// the site it reports to NOW. event_id DESC (not occurred_at) because ingest order is
// the authoritative "most recently seen" — occurred_at comes from the edge clock.
$st = $this->pdo->prepare(
'SELECT site FROM move_events WHERE camera_id = ? AND site IS NOT NULL
ORDER BY event_id DESC LIMIT 1'
);
$st->execute([$cameraId]);
$site = $st->fetchColumn();
return $site === false ? null : (string)$site;
}
private static function mapRow(array $row): array
{
return [
'event_id' => (int)$row['event_id'],
'event_uuid' => $row['event_uuid'] ?? null,
'device_id' => $row['device_id'],
'site' => $row['site'] ?? null,
'camera_id' => $row['camera_id'],
'event_type' => $row['event_type'],
'severity' => $row['severity'],
'track_id' => $row['track_id'] !== null ? (int)$row['track_id'] : null,
'zone_id' => $row['zone_id'],
'occurred_at' => (float)$row['occurred_at'],
'duration_s' => (float)$row['duration_s'],
'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,
];
}
}