pdo->prepare( 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE zone_id = ? LIMIT 1' ); $st->execute([$zoneId]); $row = $st->fetch(); return $row === false ? null : $row; } public function forDevice(string $deviceId): array { $st = $this->pdo->prepare( 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE device_id = ?' ); $st->execute([$deviceId]); return $st->fetchAll(); } public function forCamera(string $cameraId): array { $st = $this->pdo->prepare( 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE camera_id = ?' ); $st->execute([$cameraId]); return $st->fetchAll(); } public function save(array $zone): string { $zoneId = $zone['zone_id'] ?? null; $deviceId = (string)($zone['device_id'] ?? ''); $cameraId = (string)($zone['camera_id'] ?? ''); $level = (string)($zone['level'] ?? ''); $polygonJson = (string)($zone['polygon_json'] ?? '[]'); if (is_string($zoneId) && $zoneId !== '') { return $this->updateOwned($zoneId, $deviceId, $cameraId, $level, $polygonJson); } $newId = bin2hex(random_bytes(16)); $st = $this->pdo->prepare( 'INSERT INTO move_zones (zone_id, device_id, camera_id, level, polygon_json, updated_at) VALUES (?, ?, ?, ?, ?, ?)' ); $st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]); return $newId; } public function delete(string $zoneId): bool { $st = $this->pdo->prepare('DELETE FROM move_zones WHERE zone_id = ?'); $st->execute([$zoneId]); return $st->rowCount() > 0; } /** * Update path for save(): scoped by (zone_id, device_id, camera_id) so a * caller cannot overwrite an unrelated zone by supplying its id with a * different device_id/camera_id (closes the cross-tenant overwrite risk * flagged in the Task 13 review). Ownership is verified with a SELECT * before the UPDATE (rather than trusting UPDATE's rowCount()) because * MySQL reports "changed rows", not "matched rows" -- a no-op update * (identical level/polygon resubmitted) would otherwise report 0 affected * rows and be indistinguishable from an ownership mismatch. The UPDATE's * rowCount() is still checked afterwards (0 there is unambiguous, since * we already know the row existed a moment ago) to catch the narrow race * where the zone is deleted between the ownership check and the UPDATE -- * without this, save() would return success for a zone that no longer * exists. */ private function updateOwned( string $zoneId, string $deviceId, string $cameraId, string $level, string $polygonJson, ): string { $owns = $this->pdo->prepare( 'SELECT 1 FROM move_zones WHERE zone_id = ? AND device_id = ? AND camera_id = ? LIMIT 1' ); $owns->execute([$zoneId, $deviceId, $cameraId]); if ($owns->fetchColumn() === false) { throw new ZoneOwnershipException('zone not found or not owned by the given device/camera'); } $st = $this->pdo->prepare( 'UPDATE move_zones SET level = ?, polygon_json = ?, updated_at = ? WHERE zone_id = ? AND device_id = ? AND camera_id = ?' ); $st->execute([$level, $polygonJson, time(), $zoneId, $deviceId, $cameraId]); if ($st->rowCount() === 0) { // Ambiguous on its own: MySQL's rowCount() reports "changed" rows, // not "matched" rows, so 0 here could mean either (a) a genuine // no-op update (identical level/polygon resubmitted -- not an // error) or (b) the zone was deleted between the ownership check // above and this UPDATE. Disambiguate with a targeted existence // check instead of assuming either case. $stillExists = $this->pdo->prepare('SELECT 1 FROM move_zones WHERE zone_id = ? LIMIT 1'); $stillExists->execute([$zoneId]); if ($stillExists->fetchColumn() === false) { throw new ZoneOwnershipException('zone was deleted concurrently'); } } return $zoneId; } }