diff --git a/cloud/src/Dashboard/EventListController.php b/cloud/src/Dashboard/EventListController.php index fc44ffb..144ac2b 100644 --- a/cloud/src/Dashboard/EventListController.php +++ b/cloud/src/Dashboard/EventListController.php @@ -8,13 +8,41 @@ use App\Repository\EventRepository; final class EventListController { + private const DEFAULT_LIMIT = 50; + private const MAX_LIMIT = 200; + 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); $severity = $req->query('severity'); - $rows = $this->events->recent(100, $severity); - return Response::json(['events' => $rows], 200); + $limit = self::clampLimit($req->query('limit')); + [$beforeTs, $beforeId] = self::parseCursor((string)$req->query('cursor')); + $rows = $this->events->page($limit, $severity, $beforeTs, $beforeId); + + // Advertise a next cursor only on a full page — a short page means we hit the end. + // The cursor is ":" of the last (oldest) row on this page. + $next = null; + if (count($rows) === $limit && $rows !== []) { + $last = $rows[count($rows) - 1]; + $next = $last['occurred_at'] . ':' . $last['event_id']; + } + return Response::json(['events' => $rows, 'next_cursor' => $next], 200); + } + + private static function clampLimit(mixed $raw): int + { + if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_LIMIT; + return max(1, min(self::MAX_LIMIT, (int)$raw)); + } + + /** @return array{0: ?float, 1: ?int} parsed (occurred_at, event_id), or null/null if absent/malformed. */ + private static function parseCursor(string $raw): array + { + if ($raw === '' || !str_contains($raw, ':')) return [null, null]; + [$ts, $id] = explode(':', $raw, 2); + if (!is_numeric($ts) || $id === '' || !ctype_digit($id)) return [null, null]; + return [(float)$ts, (int)$id]; } } diff --git a/cloud/src/Repository/EventRepository.php b/cloud/src/Repository/EventRepository.php index 237d4ba..9b4f1ff 100644 --- a/cloud/src/Repository/EventRepository.php +++ b/cloud/src/Repository/EventRepository.php @@ -6,6 +6,13 @@ interface EventRepository { public function insert(array $event): int; public function recent(int $limit, ?string $severity): array; + /** + * Keyset (cursor) page over the (occurred_at DESC, event_id DESC) sort key. + * Pass null/null for the first page; then the (occurred_at, event_id) of the last + * returned row to fetch strictly older events. Stable under concurrent inserts — + * new events land at the head and never shift a page window. + */ + public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId): array; public function find(int $id): ?array; public function acknowledge(int $id, string $user): bool; } diff --git a/cloud/src/Repository/Pdo/PdoEventRepository.php b/cloud/src/Repository/Pdo/PdoEventRepository.php index ee95049..84de8de 100644 --- a/cloud/src/Repository/Pdo/PdoEventRepository.php +++ b/cloud/src/Repository/Pdo/PdoEventRepository.php @@ -58,6 +58,36 @@ final class PdoEventRepository implements EventRepository return array_map(self::mapRow(...), $st->fetchAll()); } + public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId): array + { + // Bind list as [value, PDO type] pairs, positional in SQL order. + $where = []; + $binds = []; + if ($severity !== null) { + $where[] = 'severity = ?'; + $binds[] = [$severity, 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 is bound as a string so the + // DECIMAL comparison keeps full precision (no float round-trip in the driver). + if ($beforeTs !== null && $beforeId !== null) { + $where[] = '(occurred_at < ? OR (occurred_at = ? AND event_id < ?))'; + $binds[] = [(string)$beforeTs, PDO::PARAM_STR]; + $binds[] = [(string)$beforeTs, PDO::PARAM_STR]; + $binds[] = [$beforeId, PDO::PARAM_INT]; + } + $sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM 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 events WHERE event_id = ?'); diff --git a/cloud/tests/DashboardTest.php b/cloud/tests/DashboardTest.php index 22de476..71b10c0 100644 --- a/cloud/tests/DashboardTest.php +++ b/cloud/tests/DashboardTest.php @@ -25,6 +25,23 @@ final class DashboardTest extends TestCase $this->assertSame(200, $res->status()); $this->assertCount(1, json_decode($res->body(), true)['events']); } + public function testListPaginatesWithCursor(): void { + $repo = new InMemoryEventRepository(); + $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 100.0]); // id 1 + $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 200.0]); // id 2 + $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 300.0]); // id 3 + $c = new EventListController($repo, $this->authed()); + // Page 1: the newest two (300, 200); a full page hands back a cursor at the oldest row. + $p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2'], []))->body(), true); + $this->assertCount(2, $p1['events']); + $this->assertSame([3, 2], [$p1['events'][0]['event_id'], $p1['events'][1]['event_id']]); + $this->assertSame('200:2', $p1['next_cursor']); + // Page 2: following the cursor yields only the older remaining event; short page => no cursor. + $p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2', 'cursor' => $p1['next_cursor']], []))->body(), true); + $this->assertCount(1, $p2['events']); + $this->assertSame(1, $p2['events'][0]['event_id']); + $this->assertNull($p2['next_cursor']); + } public function testAcknowledgeRequiresPermissionAndSetsUser(): void { $repo = new InMemoryEventRepository(); $id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']); diff --git a/cloud/tests/Support/InMemoryRepositories.php b/cloud/tests/Support/InMemoryRepositories.php index 68fa2ad..dfec277 100644 --- a/cloud/tests/Support/InMemoryRepositories.php +++ b/cloud/tests/Support/InMemoryRepositories.php @@ -29,6 +29,17 @@ final class InMemoryEventRepository implements EventRepository { if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev); return array_slice(array_values($r), 0, $limit); } + public function page(int $limit, ?string $sev, ?float $beforeTs, ?int $beforeId): array { + $r = array_values($this->rows); + if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev); + // (occurred_at DESC, event_id DESC); events inserted without occurred_at sort as 0. + usort($r, fn($a, $b) => [$b['occurred_at'] ?? 0, $b['event_id']] <=> [$a['occurred_at'] ?? 0, $a['event_id']]); + if ($beforeTs !== null && $beforeId !== null) { + $r = array_filter($r, fn($x) => ($x['occurred_at'] ?? 0) < $beforeTs + || (($x['occurred_at'] ?? 0) == $beforeTs && $x['event_id'] < $beforeId)); + } + return array_slice(array_values($r), 0, $limit); + } 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; } } diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index e510147..1182384 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -159,8 +159,13 @@ misconfig/degradation fail LOUD. `camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode captured params. DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases. -- [ ] **P4-5 [M]** `cloud/src/Dashboard/EventListController.php:17` — event list hard-capped at +- [x] **P4-5 [M]** `cloud/src/Dashboard/EventListController.php:17` — event list hard-capped at newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination. + DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory), + a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes + `limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full + pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a + history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`. - [x] **P4-6 [M]** `cloud/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a clear error.