diff --git a/docs/INDEX.md b/docs/INDEX.md index b548af1..6ded068 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,13 @@ # INDEX — 작업 기록 (완료 결과만) +- **2026-08-08** 프로젝트 8개 차원 감사(종합 82/100) 후 상위 3건 수정. ①개선 조치 site 스코프 + 누락(스코프 운영자가 타 현장 조치 기록 열람·기록 가능) → `move_actions.site` 추가 + (`2026-08-08-actions-site.sql`, 이벤트에서 카메라 소유 현장 해석, 오타 카메라 400·타 현장 + 404). ②README-UPLOAD 업그레이드 절차가 마이그레이션 1/7만 지시 → 순서·적용조건 표로 교체 + (`move_` 접두 리네임 누락 시 전 요청 500). ③두 납품 번들 재생성(4커밋 스테일 해소 — + Report/Export/Actions 컨트롤러·마이그레이션 누락 복구, 엣지 소스 패리티 회복). + 문서 테이블 수·프리픽스 표기 정정. pytest 235 / PHPUnit 147. + - **2026-08-07** 데이터 활용 5안 중 4안 구현: ②시간패턴 히트맵+전조 급증 경보 ③KPI 전기간 추이(`d8b8e1f`) ①라벨 JSONL 익스포트(`/api/export/labels`, 관리자) ④개선 조치 기록·전후 효과 비교(`move_actions`, 동일 창 비교, 3일 미만 "측정 중"). ⑤멀티사이트 벤치마크는 다현장 diff --git a/docs/pilot-runbook.md b/docs/pilot-runbook.md index 5ae0887..5bb0e1a 100644 --- a/docs/pilot-runbook.md +++ b/docs/pilot-runbook.md @@ -58,15 +58,15 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose. 로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은 [server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만. -1. DB 생성 후 스키마 임포트: `mysql < server/schema.sql` (10개 테이블). +1. DB 생성 후 스키마 임포트: `mysql < server/schema.sql` (11개 테이블, 전부 `move_` 접두사). 2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입 (`snapshot_dir`는 쓰기 가능 경로로). 3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다. CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용: - 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"` - - `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);` + - `INSERT INTO move_devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);` - 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"` - - `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);` + - `INSERT INTO move_users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);` 4. 기동: `php -S localhost:8000 -t server/public` - 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개 `localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조. diff --git a/server/deploy/README-UPLOAD.md b/server/deploy/README-UPLOAD.md index 6d1fd4a..dd12163 100644 --- a/server/deploy/README-UPLOAD.md +++ b/server/deploy/README-UPLOAD.md @@ -70,19 +70,40 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다. 1. 카페24 관리자 → **phpMyAdmin** 열기 2. 이 앱용 DB 선택 3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행 -4. 표 10개가 생겼는지 확인 +4. 표 11개가 생겼는지 확인 5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다 +`schema.sql` 은 항상 최신 상태입니다. 처음 설치라면 아래 마이그레이션은 **실행하지 마세요** +(이미 반영되어 있어 충돌합니다). + ### 이미 쓰던 DB 업그레이드(기존 데이터 있음) > ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다. > 대신 **마이그레이션**을 실행하세요. +**반드시 아래 순서대로, 아직 적용하지 않은 것부터 전부 실행합니다.** 하나라도 건너뛰면 +그 컬럼·표를 쓰는 요청이 전부 500 이 됩니다. 순서가 중요한 이유는 뒤 파일이 앞 파일이 만든 +표 이름(`move_` 접두사)을 전제하기 때문입니다. + +| 순서 | 파일 | 무엇이 바뀌나 | +|---|---|---| +| 1 | `2026-07-17-p1-9-p3-2b.sql` | site / event_uuid 컬럼 + 인덱스, 기존 이벤트 site 백필 | +| 2 | `2026-07-20-move-table-prefix.sql` | 모든 표 이름에 `move_` 접두사 부여 (**이 단계를 건너뛰면 이후 전부 실패**) | +| 3 | `2026-08-06-zone-rules.sql` | 존 점유 규칙(`rules_json`) | +| 4 | `2026-08-06-heatmap-spatial.sql` | 히트맵 좌표(`spatial_x`, `spatial_y`) | +| 5 | `2026-08-06-false-positive-tag.sql` | 오탐 태그 컬럼 | +| 6 | `2026-08-07-improvement-actions.sql` | 개선 조치 표(`move_actions`) | +| 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 | + 1. phpMyAdmin → 해당 DB → **SQL** 탭 -2. `migrations/2026-07-17-p1-9-p3-2b.sql` 내용을 붙여넣고 실행 - (site / event_uuid 컬럼 + 인덱스 추가, 기존 이벤트의 site 백필 포함) -3. 파일 맨 아래 **검증 쿼리**로 컬럼·인덱스가 생겼는지 확인 -4. 그 다음에 새 코드를 배포합니다 — 컬럼이 없으면 모든 요청이 500 이 됩니다 +2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다 + (각 파일 첫머리에 적용 조건이 주석으로 적혀 있습니다) +3. 각 파일 맨 아래 **검증 쿼리**로 컬럼·표가 생겼는지 확인 +4. 전부 끝난 뒤에 새 코드를 배포합니다 + +> 이미 적용한 마이그레이션을 다시 실행하면 "Duplicate column" 같은 오류가 납니다 — +> 재실행은 안전하지 않으니, 어디까지 적용했는지 기록해 두세요. 어디까지 했는지 모르겠으면 +> phpMyAdmin 의 **Structure(구조)** 탭에서 위 표의 컬럼·표가 있는지 하나씩 확인하면 됩니다. --- diff --git a/server/docs/server-deploy.md b/server/docs/server-deploy.md index a157d13..02773d7 100644 --- a/server/docs/server-deploy.md +++ b/server/docs/server-deploy.md @@ -139,13 +139,14 @@ or version control. ## 4. Import the schema -`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`, -`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`). +`server/schema.sql` contains the full MySQL 8.0+ DDL. All tables carry the `move_` prefix +(`move_users`, `move_devices`, `move_cameras`, `move_actions`, `move_zones`, `move_events`, +`move_event_snapshots`, `move_thresholds`, `move_nonces`, `move_rate_limits`, `move_audit_log`). 1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this app. 2. **Import** tab → choose file → upload `schema.sql` → Go. -3. Confirm all 10 tables were created (phpMyAdmin's table list on the left, or +3. Confirm all 11 tables were created (phpMyAdmin's table list on the left, or `SHOW TABLES;` in the SQL tab). 4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live on the host afterwards, and leaving DDL lying around outside the web root is unnecessary diff --git a/server/migrations/2026-08-08-actions-site.sql b/server/migrations/2026-08-08-actions-site.sql new file mode 100644 index 0000000..adcea85 --- /dev/null +++ b/server/migrations/2026-08-08-actions-site.sql @@ -0,0 +1,22 @@ +-- 개선 조치(move_actions)에 site 스코프 추가. +-- 이 표만 site 컬럼 없이 추가되어, 스코프 운영자가 타 현장의 조치 기록(카메라 ID·메모· +-- 작성자)을 목록에서 그대로 읽을 수 있었다. 다른 표(events/devices/zones)와 동일하게 +-- site 를 비정규화해 SQL 단계에서 걸러낸다. +-- +-- 적용 조건: 2026-08-07-improvement-actions.sql 이후. + +ALTER TABLE move_actions + ADD COLUMN site VARCHAR(64) NULL AFTER camera_id, + ADD KEY ix_actions_site_taken (site, taken_at); + +-- 기존 행 백필: 조치가 달린 카메라가 이벤트를 남긴 현장을 그 조치의 현장으로 본다. +-- (카메라는 별도 등록 표가 없고 events.site 가 유일한 소유 근거다.) +UPDATE move_actions a + SET a.site = (SELECT e.site FROM move_events e + WHERE e.camera_id = a.camera_id AND e.site IS NOT NULL + ORDER BY e.event_id DESC LIMIT 1) + WHERE a.site IS NULL; + +-- 검증: site 가 채워졌는지 확인한다. NULL 로 남은 행은 해당 카메라의 이벤트가 없거나 +-- 현장 미지정(devices.site IS NULL)인 경우로, 전역 관리자에게만 보인다. +-- SELECT site, COUNT(*) FROM move_actions GROUP BY site; diff --git a/server/schema.sql b/server/schema.sql index 8870f95..7068d69 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -101,12 +101,14 @@ CREATE TABLE move_cameras ( CREATE TABLE move_actions ( action_id INT UNSIGNED NOT NULL AUTO_INCREMENT, camera_id VARCHAR(64) NOT NULL, + site VARCHAR(64) NULL, -- access-control scope, resolved from the camera's events at insert note VARCHAR(500) NOT NULL, taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s) created_by VARCHAR(190) NOT NULL, created_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (action_id), - KEY ix_actions_taken (taken_at) + KEY ix_actions_taken (taken_at), + KEY ix_actions_site_taken (site, taken_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE move_zones ( diff --git a/server/src/Dashboard/ActionsController.php b/server/src/Dashboard/ActionsController.php index ccc9d5e..44c2d59 100644 --- a/server/src/Dashboard/ActionsController.php +++ b/server/src/Dashboard/ActionsController.php @@ -4,7 +4,7 @@ namespace App\Dashboard; use App\Http\{Request, Response}; use App\Auth\{SessionStore, Rbac}; -use App\Security\Csrf; +use App\Security\{Csrf, SiteScope}; use App\Repository\{ActionRepository, EventRepository}; /** @@ -44,8 +44,22 @@ final class ActionsController if ($takenAt > $now || $takenAt < $now - 365 * 86400) { return Response::json(['error' => 'invalid_taken_at'], 400); } + // The camera MUST be one that has actually reported: a mistyped id would record an action + // whose before/after effect is 0/0 forever — a safety investment that silently never gets + // measured. Cameras have no registry table, so their events are the ownership record. + // 400 (client input error), matching ZoneConfigController's unknown_device. + if (!$this->events->cameraExists($camera)) { + return Response::json(['error' => 'unknown_camera'], 400); + } + // Scope BEFORE the write: a scoped operator must not record an action against another + // site's camera (it would pollute that site's report). 404 hides existence, as everywhere. + $site = $this->events->siteForCamera($camera); + $scope = $this->sess->get('site_scope'); + if (!SiteScope::allows(is_string($scope) ? $scope : null, $site)) { + return Response::json(['error' => 'not_found'], 404); + } $id = $this->actions->insert([ - 'camera_id' => $camera, 'note' => $note, 'taken_at' => $takenAt, + 'camera_id' => $camera, 'site' => $site, 'note' => $note, 'taken_at' => $takenAt, 'created_by' => (string)$this->sess->get('username'), ]); return Response::json(['action_id' => $id], 201); @@ -58,7 +72,7 @@ final class ActionsController $site = is_string($scope) ? $scope : null; $now = time(); $out = []; - foreach ($this->actions->recent(self::LIST_LIMIT) as $a) { + foreach ($this->actions->recent(self::LIST_LIMIT, $site) as $a) { // 동일 길이의 전후 창으로만 비교해야 공정하다: 시행 후 경과가 W보다 짧으면 // 전 창도 그만큼으로 줄인다. 3일 미만은 표본이 아니라 소음 — "측정 중". $elapsed = $now - $a['taken_at']; diff --git a/server/src/Repository/ActionRepository.php b/server/src/Repository/ActionRepository.php index 1754ec6..b0163a3 100644 --- a/server/src/Repository/ActionRepository.php +++ b/server/src/Repository/ActionRepository.php @@ -4,8 +4,12 @@ namespace App\Repository; interface ActionRepository { - /** {camera_id, note, taken_at, created_by} -> action_id */ + /** {camera_id, site, note, taken_at, created_by} -> action_id */ public function insert(array $action): int; - /** Most recent actions, newest taken_at first, capped at $limit. */ - public function recent(int $limit): array; + /** + * Most recent actions, newest taken_at first, capped at $limit. + * $site (non-null) restricts to that site — the SQL half of the site-scope access + * control, same contract as EventRepository::page() (see App\Security\SiteScope). + */ + public function recent(int $limit, ?string $site): array; } diff --git a/server/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php index e334326..f9e1560 100644 --- a/server/src/Repository/EventRepository.php +++ b/server/src/Repository/EventRepository.php @@ -59,4 +59,17 @@ interface EventRepository public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array; /** Detections (non-ergo) for one camera in [from, to) — the action before/after counter. */ public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int; + /** + * True if this camera has ever reported an event — the closest thing to "does this camera + * exist", playing the role DeviceRepository::exists() plays for devices. + * NOT move_cameras: that table has no registration flow and no repository (see schema.sql), + * so it is empty in every deployment — checking it would reject every real camera. + */ + public function cameraExists(string $cameraId): bool; + /** + * The site this camera's events are recorded under (from its most recent event), or null + * when the camera is unknown OR its device has no site assigned. Pair with cameraExists() + * to tell those two apart, exactly as DeviceRepository::exists()/siteFor() are paired. + */ + public function siteForCamera(string $cameraId): ?string; } diff --git a/server/src/Repository/Pdo/PdoActionRepository.php b/server/src/Repository/Pdo/PdoActionRepository.php index f3ef733..507e90f 100644 --- a/server/src/Repository/Pdo/PdoActionRepository.php +++ b/server/src/Repository/Pdo/PdoActionRepository.php @@ -12,11 +12,12 @@ final class PdoActionRepository implements ActionRepository public function insert(array $action): int { $st = $this->pdo->prepare( - 'INSERT INTO move_actions (camera_id, note, taken_at, created_by, created_at) - VALUES (?, ?, ?, ?, ?)' + 'INSERT INTO move_actions (camera_id, site, note, taken_at, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?)' ); $st->execute([ (string)$action['camera_id'], + $action['site'] ?? null, // denormalized from the camera's events by ActionsController (string)$action['note'], (int)$action['taken_at'], (string)$action['created_by'], @@ -25,17 +26,26 @@ final class PdoActionRepository implements ActionRepository return (int)$this->pdo->lastInsertId(); } - public function recent(int $limit): array + public function recent(int $limit, ?string $site): array { - $st = $this->pdo->prepare( - 'SELECT action_id, camera_id, note, taken_at, created_by - FROM move_actions ORDER BY taken_at DESC, action_id DESC LIMIT ?' - ); - $st->bindValue(1, $limit, PDO::PARAM_INT); + $sql = 'SELECT action_id, camera_id, site, note, taken_at, created_by FROM move_actions'; + $binds = []; + if ($site !== null) { + // Fail-closed like every other scoped list: an empty-string scope matches nothing, + // and a row with no site is never visible to a scoped caller. + $sql .= ' WHERE site = ?'; + $binds[] = $site; + } + $sql .= ' ORDER BY taken_at DESC, action_id DESC LIMIT ?'; + $st = $this->pdo->prepare($sql); + $pos = 1; + foreach ($binds as $b) $st->bindValue($pos++, $b, PDO::PARAM_STR); + $st->bindValue($pos, $limit, PDO::PARAM_INT); $st->execute(); return array_map(static fn ($r) => [ 'action_id' => (int)$r['action_id'], 'camera_id' => $r['camera_id'], + 'site' => $r['site'], 'note' => $r['note'], 'taken_at' => (int)$r['taken_at'], 'created_by' => $r['created_by'], diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php index e596ca3..e92ade7 100644 --- a/server/src/Repository/Pdo/PdoEventRepository.php +++ b/server/src/Repository/Pdo/PdoEventRepository.php @@ -336,6 +336,27 @@ final class PdoEventRepository implements EventRepository return (int)$st->fetchColumn(); } + 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 [ diff --git a/server/tests/ExportActionsTest.php b/server/tests/ExportActionsTest.php index 43e1dce..53b36bf 100644 --- a/server/tests/ExportActionsTest.php +++ b/server/tests/ExportActionsTest.php @@ -67,19 +67,63 @@ final class ExportActionsTest extends TestCase return new Request('POST', '/api/actions', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []); } + /** 카메라는 등록 표가 없다 — 이벤트가 곧 존재·소유 근거이므로 테스트도 이벤트로 만든다. */ + private function eventsWithCameras(array $cameraSites): InMemoryEventRepository + { + $events = new InMemoryEventRepository(); + foreach ($cameraSites as $cam => $site) { + $events->insert(['camera_id' => $cam, 'site' => $site, 'event_type' => 'near_fall', + 'severity' => 'info', 'occurred_at' => (float)time(), 'metrics' => []]); + } + return $events; + } + public function testActionSaveValidation(): void { - $c = new ActionsController(new InMemoryActionRepository(), new InMemoryEventRepository(), $this->sess('operator')); + $events = $this->eventsWithCameras(['cam-1' => null]); + $c = new ActionsController(new InMemoryActionRepository(), $events, $this->sess('operator')); $this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '매트 설치']))->status()); $this->assertSame(400, $c->save($this->actionReq(['camera_id' => '', 'note' => 'x']))->status()); $this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '']))->status()); $this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x', 'taken_at' => time() + 999]))->status()); // 미래 시각 거부 + // 오타 카메라: 전후 효과가 영원히 0/0이 되므로 기록 자체를 막는다 + $this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-오타', 'note' => 'x']))->status()); // viewer는 기록 불가 - $cv = new ActionsController(new InMemoryActionRepository(), new InMemoryEventRepository(), $this->sess('viewer')); + $cv = new ActionsController(new InMemoryActionRepository(), $events, $this->sess('viewer')); $this->assertSame(403, $cv->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x']))->status()); } + public function testScopedOperatorCannotWriteActionForAnotherSiteCamera(): void + { + $events = $this->eventsWithCameras(['cam-a' => 'site-a', 'cam-b' => 'site-b']); + $actions = new InMemoryActionRepository(); + $c = new ActionsController($actions, $events, $this->sess('operator', 'site-a')); + $this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-a', 'note' => '매트']))->status()); + // 타 현장 카메라: 존재 여부를 숨기며 거부(404) — 그 현장 리포트 오염 방지 + $this->assertSame(404, $c->save($this->actionReq(['camera_id' => 'cam-b', 'note' => '침입']))->status()); + $this->assertCount(1, $actions->rows); + $this->assertSame('site-a', $actions->rows[1]['site']); // 카메라에서 해석해 비정규화 + } + + public function testScopedOperatorSeesOnlyOwnSiteActions(): void + { + $actions = new InMemoryActionRepository(); + $actions->insert(['camera_id' => 'cam-a', 'site' => 'site-a', 'note' => '우리 현장', + 'taken_at' => time() - 86400, 'created_by' => 'a']); + $actions->insert(['camera_id' => 'cam-b', 'site' => 'site-b', 'note' => '남의 현장', + 'taken_at' => time() - 3600, 'created_by' => 'b']); + $req = new Request('GET', '/api/actions', [], '', [], []); + + $scoped = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('operator', 'site-a')); + $rows = json_decode($scoped->json($req)->body(), true)['actions']; + $this->assertCount(1, $rows); + $this->assertSame('우리 현장', $rows[0]['note']); // 타 현장 메모·작성자 미노출 + + $global = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('admin')); + $this->assertCount(2, json_decode($global->json($req)->body(), true)['actions']); + } + public function testActionEffectComparesEqualWindows(): void { $actions = new InMemoryActionRepository(); diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php index efadb8f..9f75579 100644 --- a/server/tests/Support/InMemoryRepositories.php +++ b/server/tests/Support/InMemoryRepositories.php @@ -129,6 +129,18 @@ final class InMemoryEventRepository implements EventRepository { && ($x['event_type'] ?? '') !== 'ergo_report' && ($site === null || ($x['site'] ?? null) === $site))); } + public function cameraExists(string $cam): bool { + foreach ($this->rows as $r) if (($r['camera_id'] ?? '') === $cam) return true; + return false; + } + public function siteForCamera(string $cam): ?string { + $best = null; + foreach ($this->rows as $r) { + if (($r['camera_id'] ?? '') !== $cam || ($r['site'] ?? null) === null) continue; + if ($best === null || ($r['event_id'] ?? 0) >= ($best['event_id'] ?? 0)) $best = $r; + } + return $best === null ? null : (string)$best['site']; + } public function occurrenceTimes(float $since, ?string $site, int $limit): array { $r = array_filter($this->rows, fn($x) => ($x['occurred_at'] ?? 0) >= $since && ($x['event_type'] ?? '') !== 'ergo_report' @@ -218,8 +230,9 @@ final class InMemoryZoneRepository implements ZoneRepository { final class InMemoryActionRepository implements ActionRepository { public array $rows = []; private int $seq = 0; public function insert(array $a): int { $id = ++$this->seq; $this->rows[$id] = $a + ['action_id' => $id]; return $id; } - public function recent(int $limit): array { - $r = array_values($this->rows); + public function recent(int $limit, ?string $site): array { + $r = array_values(array_filter($this->rows, + fn($a) => $site === null || ($a['site'] ?? null) === $site)); usort($r, fn($a, $b) => [$b['taken_at'], $b['action_id']] <=> [$a['taken_at'], $a['action_id']]); return array_slice($r, 0, $limit); }