diff --git a/migrations/004_add_camera_to_gates.sql b/migrations/004_add_camera_to_gates.sql new file mode 100644 index 0000000..c151653 --- /dev/null +++ b/migrations/004_add_camera_to_gates.sql @@ -0,0 +1,15 @@ +-- Migration: Add camera field to gates table +-- Description: Tambah field camera untuk menyimpan URL atau identifier kamera di setiap gate +-- Supports: HLS (.m3u8), RTSP, HTTP streaming, camera ID, dll +-- Date: 2025-01-17 + +-- Add camera column to gates table +ALTER TABLE gates +ADD COLUMN camera VARCHAR(500) NULL +COMMENT 'URL streaming kamera (HLS .m3u8, RTSP, HTTP) atau identifier kamera untuk gate ini' +AFTER direction; + +-- Add index untuk performa query jika perlu filter by camera +-- (optional, uncomment jika diperlukan) +-- CREATE INDEX idx_camera ON gates(camera); + diff --git a/migrations/README.md b/migrations/README.md index 9ce59c7..765d69c 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -48,6 +48,25 @@ DESCRIBE audit_logs; - **Tabel**: `audit_logs` - **Rollback**: Tidak ada (tabel ini critical untuk audit, tidak boleh dihapus) +### 002_create_hourly_summary.sql +- **Tanggal**: 2024-12-28 +- **Deskripsi**: Membuat tabel `hourly_summary` untuk rekap per jam +- **Tabel**: `hourly_summary` + +### 003_create_realtime_events.sql +- **Tanggal**: 2024-12-28 +- **Deskripsi**: Membuat tabel `realtime_events` untuk ring buffer SSE events +- **Tabel**: `realtime_events` + +### 004_add_camera_to_gates.sql +- **Tanggal**: 2025-01-17 +- **Deskripsi**: Menambahkan field `camera` ke tabel `gates` untuk menyimpan URL atau identifier kamera +- **Tabel**: `gates` +- **Rollback**: +```sql +ALTER TABLE gates DROP COLUMN camera; +``` + ## Catatan Penting - **JANGAN** hapus atau modify migration file yang sudah di-apply diff --git a/src/Modules/Retribusi/Frontend/AuditController.php b/src/Modules/Retribusi/Frontend/AuditController.php new file mode 100644 index 0000000..62c4585 --- /dev/null +++ b/src/Modules/Retribusi/Frontend/AuditController.php @@ -0,0 +1,98 @@ +auditService = $auditService; + } + + public function getAuditLogs( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + $queryParams = $request->getQueryParams(); + [$page, $limit] = Validator::validatePagination($queryParams); + + $entity = $queryParams['entity'] ?? null; + if ($entity !== null && !is_string($entity)) { + $entity = null; + } + + $action = $queryParams['action'] ?? null; + if ($action !== null && !is_string($action)) { + $action = null; + } + + $entityKey = $queryParams['entity_key'] ?? null; + if ($entityKey !== null && !is_string($entityKey)) { + $entityKey = null; + } + + $startDate = $queryParams['start_date'] ?? null; + if ($startDate !== null && !is_string($startDate)) { + $startDate = null; + } + + $endDate = $queryParams['end_date'] ?? null; + if ($endDate !== null && !is_string($endDate)) { + $endDate = null; + } + + try { + $data = $this->auditService->getAuditLogs( + $page, + $limit, + $entity, + $action, + $entityKey, + $startDate, + $endDate + ); + $total = $this->auditService->getAuditLogsTotal( + $entity, + $action, + $entityKey, + $startDate, + $endDate + ); + + return ResponseHelper::json( + $response, + [ + 'success' => true, + 'data' => $data, + 'meta' => [ + 'page' => $page, + 'limit' => $limit, + 'total' => $total, + 'pages' => (int) ceil($total / $limit) + ], + 'timestamp' => time() + ] + ); + } catch (PDOException $e) { + return ResponseHelper::json( + $response, + [ + 'error' => 'server_error', + 'message' => 'Database error occurred' + ], + 500 + ); + } + } +} + diff --git a/src/Modules/Retribusi/Frontend/AuditService.php b/src/Modules/Retribusi/Frontend/AuditService.php index b7c889f..391ee46 100644 --- a/src/Modules/Retribusi/Frontend/AuditService.php +++ b/src/Modules/Retribusi/Frontend/AuditService.php @@ -68,6 +68,149 @@ class AuditService ]); } + /** + * Get audit logs with pagination and optional filters + * + * @param int $page + * @param int $limit + * @param string|null $entity Optional filter by entity (locations|gates|tariffs) + * @param string|null $action Optional filter by action (create|update|delete) + * @param string|null $entityKey Optional filter by entity key + * @param string|null $startDate Optional start date (YYYY-MM-DD) + * @param string|null $endDate Optional end date (YYYY-MM-DD) + * @return array + * @throws PDOException + */ + public function getAuditLogs( + int $page, + int $limit, + ?string $entity = null, + ?string $action = null, + ?string $entityKey = null, + ?string $startDate = null, + ?string $endDate = null + ): array { + $offset = ($page - 1) * $limit; + $where = []; + $params = []; + + if ($entity !== null) { + $where[] = 'entity = ?'; + $params[] = $entity; + } + + if ($action !== null) { + $where[] = 'action = ?'; + $params[] = $action; + } + + if ($entityKey !== null) { + $where[] = 'entity_key = ?'; + $params[] = $entityKey; + } + + if ($startDate !== null) { + $where[] = 'DATE(created_at) >= ?'; + $params[] = $startDate; + } + + if ($endDate !== null) { + $where[] = 'DATE(created_at) <= ?'; + $params[] = $endDate; + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + $sql = "SELECT id, actor_user_id, actor_username, actor_role, action, entity, entity_key, + before_json, after_json, ip_address, user_agent, created_at + FROM audit_logs + {$whereClause} + ORDER BY created_at DESC + LIMIT ? OFFSET ?"; + + $stmt = $this->db->prepare($sql); + $paramIndex = 1; + foreach ($params as $param) { + $stmt->bindValue($paramIndex++, $param, PDO::PARAM_STR); + } + $stmt->bindValue($paramIndex++, $limit, PDO::PARAM_INT); + $stmt->bindValue($paramIndex, $offset, PDO::PARAM_INT); + $stmt->execute(); + + $results = $stmt->fetchAll(); + + // Parse JSON fields + foreach ($results as &$result) { + if ($result['before_json'] !== null) { + $result['before_json'] = json_decode($result['before_json'], true); + } + if ($result['after_json'] !== null) { + $result['after_json'] = json_decode($result['after_json'], true); + } + } + + return $results; + } + + /** + * Get total count of audit logs + * + * @param string|null $entity Optional filter by entity + * @param string|null $action Optional filter by action + * @param string|null $entityKey Optional filter by entity key + * @param string|null $startDate Optional start date + * @param string|null $endDate Optional end date + * @return int + * @throws PDOException + */ + public function getAuditLogsTotal( + ?string $entity = null, + ?string $action = null, + ?string $entityKey = null, + ?string $startDate = null, + ?string $endDate = null + ): int { + $where = []; + $params = []; + + if ($entity !== null) { + $where[] = 'entity = ?'; + $params[] = $entity; + } + + if ($action !== null) { + $where[] = 'action = ?'; + $params[] = $action; + } + + if ($entityKey !== null) { + $where[] = 'entity_key = ?'; + $params[] = $entityKey; + } + + if ($startDate !== null) { + $where[] = 'DATE(created_at) >= ?'; + $params[] = $startDate; + } + + if ($endDate !== null) { + $where[] = 'DATE(created_at) <= ?'; + $params[] = $endDate; + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT COUNT(*) FROM audit_logs {$whereClause}"; + + if (!empty($params)) { + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + } else { + $stmt = $this->db->query($sql); + } + + return (int) $stmt->fetchColumn(); + } + /** * Get client IP address from request * diff --git a/src/Modules/Retribusi/Frontend/EntryEventsController.php b/src/Modules/Retribusi/Frontend/EntryEventsController.php new file mode 100644 index 0000000..960823e --- /dev/null +++ b/src/Modules/Retribusi/Frontend/EntryEventsController.php @@ -0,0 +1,99 @@ +realtimeService = $realtimeService; + } + + public function getEntryEvents( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + $queryParams = $request->getQueryParams(); + [$page, $limit] = Validator::validatePagination($queryParams); + + $locationCode = $queryParams['location_code'] ?? null; + if ($locationCode !== null && !is_string($locationCode)) { + $locationCode = null; + } + + $gateCode = $queryParams['gate_code'] ?? null; + if ($gateCode !== null && !is_string($gateCode)) { + $gateCode = null; + } + + $category = $queryParams['category'] ?? null; + if ($category !== null && !is_string($category)) { + $category = null; + } + + $startDate = $queryParams['start_date'] ?? null; + if ($startDate !== null && !is_string($startDate)) { + $startDate = null; + } + + $endDate = $queryParams['end_date'] ?? null; + if ($endDate !== null && !is_string($endDate)) { + $endDate = null; + } + + try { + $data = $this->realtimeService->getEntryEvents( + $page, + $limit, + $locationCode, + $gateCode, + $category, + $startDate, + $endDate + ); + $total = $this->realtimeService->getEntryEventsTotal( + $locationCode, + $gateCode, + $category, + $startDate, + $endDate + ); + + return ResponseHelper::json( + $response, + [ + 'success' => true, + 'data' => $data, + 'meta' => [ + 'page' => $page, + 'limit' => $limit, + 'total' => $total, + 'pages' => (int) ceil($total / $limit) + ], + 'timestamp' => time() + ] + ); + } catch (PDOException $e) { + return ResponseHelper::json( + $response, + [ + 'error' => 'server_error', + 'message' => 'Database error occurred' + ], + 500 + ); + } + } +} + diff --git a/src/Modules/Retribusi/Frontend/GateController.php b/src/Modules/Retribusi/Frontend/GateController.php index 2aeb87d..a2cd514 100644 --- a/src/Modules/Retribusi/Frontend/GateController.php +++ b/src/Modules/Retribusi/Frontend/GateController.php @@ -57,6 +57,48 @@ class GateController ); } + public function getGate( + ServerRequestInterface $request, + ResponseInterface $response, + array $args + ): ResponseInterface { + $locationCode = $args['location_code'] ?? null; + $gateCode = $args['gate_code'] ?? null; + + if ($locationCode === null || !is_string($locationCode) || + $gateCode === null || !is_string($gateCode)) { + return ResponseHelper::json( + $response, + [ + 'error' => 'validation_error', + 'fields' => ['location_code' => 'Invalid location_code or gate_code'] + ], + 422 + ); + } + + $data = $this->writeService->getGate($locationCode, $gateCode); + if ($data === null) { + return ResponseHelper::json( + $response, + [ + 'error' => 'not_found', + 'message' => 'Gate not found' + ], + 404 + ); + } + + return ResponseHelper::json( + $response, + [ + 'success' => true, + 'data' => $data, + 'timestamp' => time() + ] + ); + } + public function createGate( ServerRequestInterface $request, ResponseInterface $response diff --git a/src/Modules/Retribusi/Frontend/LocationController.php b/src/Modules/Retribusi/Frontend/LocationController.php index 1391b94..f1a13d7 100644 --- a/src/Modules/Retribusi/Frontend/LocationController.php +++ b/src/Modules/Retribusi/Frontend/LocationController.php @@ -52,6 +52,45 @@ class LocationController ); } + public function getLocation( + ServerRequestInterface $request, + ResponseInterface $response, + array $args + ): ResponseInterface { + $code = $args['code'] ?? null; + if ($code === null || !is_string($code)) { + return ResponseHelper::json( + $response, + [ + 'error' => 'validation_error', + 'fields' => ['code' => 'Invalid location code'] + ], + 422 + ); + } + + $data = $this->writeService->getLocation($code); + if ($data === null) { + return ResponseHelper::json( + $response, + [ + 'error' => 'not_found', + 'message' => 'Location not found' + ], + 404 + ); + } + + return ResponseHelper::json( + $response, + [ + 'success' => true, + 'data' => $data, + 'timestamp' => time() + ] + ); + } + public function createLocation( ServerRequestInterface $request, ResponseInterface $response diff --git a/src/Modules/Retribusi/Frontend/RetribusiReadService.php b/src/Modules/Retribusi/Frontend/RetribusiReadService.php index 5c8123d..5f0e2c1 100644 --- a/src/Modules/Retribusi/Frontend/RetribusiReadService.php +++ b/src/Modules/Retribusi/Frontend/RetribusiReadService.php @@ -69,7 +69,7 @@ class RetribusiReadService if ($locationCode !== null) { $stmt = $this->db->prepare( - 'SELECT g.location_code, g.gate_code, g.name, g.direction, g.is_active, + 'SELECT g.location_code, g.gate_code, g.name, g.direction, g.camera, g.is_active, l.name as location_name FROM gates g INNER JOIN locations l ON g.location_code = l.code @@ -83,7 +83,7 @@ class RetribusiReadService $stmt->execute(); } else { $stmt = $this->db->prepare( - 'SELECT g.location_code, g.gate_code, g.name, g.direction, g.is_active, + 'SELECT g.location_code, g.gate_code, g.name, g.direction, g.camera, g.is_active, l.name as location_name FROM gates g INNER JOIN locations l ON g.location_code = l.code @@ -142,4 +142,90 @@ class RetribusiReadService { return $this->getGatesTotal($locationCode); } + + /** + * Get tariffs list with pagination and optional filters + * + * @param int $page + * @param int $limit + * @param string|null $locationCode Optional filter by location + * @param string|null $gateCode Optional filter by gate + * @return array + * @throws PDOException + */ + public function getTariffs(int $page, int $limit, ?string $locationCode = null, ?string $gateCode = null): array + { + $offset = ($page - 1) * $limit; + $where = []; + $params = []; + + if ($locationCode !== null) { + $where[] = 't.location_code = ?'; + $params[] = $locationCode; + } + + if ($gateCode !== null) { + $where[] = 't.gate_code = ?'; + $params[] = $gateCode; + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + $sql = "SELECT t.location_code, t.gate_code, t.category, t.price, + l.name as location_name, + g.name as gate_name + FROM tariffs t + INNER JOIN locations l ON t.location_code = l.code + INNER JOIN gates g ON t.location_code = g.location_code AND t.gate_code = g.gate_code + {$whereClause} + ORDER BY t.location_code, t.gate_code, t.category ASC + LIMIT ? OFFSET ?"; + + $stmt = $this->db->prepare($sql); + $paramIndex = 1; + foreach ($params as $param) { + $stmt->bindValue($paramIndex++, $param, PDO::PARAM_STR); + } + $stmt->bindValue($paramIndex++, $limit, PDO::PARAM_INT); + $stmt->bindValue($paramIndex, $offset, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } + + /** + * Get total count of tariffs + * + * @param string|null $locationCode Optional filter by location + * @param string|null $gateCode Optional filter by gate + * @return int + * @throws PDOException + */ + public function getTariffsTotal(?string $locationCode = null, ?string $gateCode = null): int + { + $where = []; + $params = []; + + if ($locationCode !== null) { + $where[] = 'location_code = ?'; + $params[] = $locationCode; + } + + if ($gateCode !== null) { + $where[] = 'gate_code = ?'; + $params[] = $gateCode; + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT COUNT(*) FROM tariffs {$whereClause}"; + + if (!empty($params)) { + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + } else { + $stmt = $this->db->query($sql); + } + + return (int) $stmt->fetchColumn(); + } } diff --git a/src/Modules/Retribusi/Frontend/RetribusiWriteService.php b/src/Modules/Retribusi/Frontend/RetribusiWriteService.php index e31dd32..719036a 100644 --- a/src/Modules/Retribusi/Frontend/RetribusiWriteService.php +++ b/src/Modules/Retribusi/Frontend/RetribusiWriteService.php @@ -127,7 +127,7 @@ class RetribusiWriteService public function getGate(string $locationCode, string $gateCode): ?array { $stmt = $this->db->prepare( - 'SELECT location_code, gate_code, name, direction, is_active + 'SELECT location_code, gate_code, name, direction, camera, is_active FROM gates WHERE location_code = ? AND gate_code = ? LIMIT 1' @@ -147,10 +147,11 @@ class RetribusiWriteService public function createGate(array $data): array { $direction = isset($data['direction']) ? strtolower($data['direction']) : $data['direction']; + $camera = $data['camera'] ?? null; $stmt = $this->db->prepare( - 'INSERT INTO gates (location_code, gate_code, name, direction, is_active) - VALUES (?, ?, ?, ?, ?)' + 'INSERT INTO gates (location_code, gate_code, name, direction, camera, is_active) + VALUES (?, ?, ?, ?, ?, ?)' ); $stmt->execute([ @@ -158,6 +159,7 @@ class RetribusiWriteService $data['gate_code'], $data['name'], $direction, + $camera, $data['is_active'] ]); @@ -188,6 +190,11 @@ class RetribusiWriteService $params[] = strtolower($data['direction']); } + if (isset($data['camera'])) { + $updates[] = 'camera = ?'; + $params[] = $data['camera']; + } + if (isset($data['is_active'])) { $updates[] = 'is_active = ?'; $params[] = $data['is_active']; diff --git a/src/Modules/Retribusi/Frontend/TariffController.php b/src/Modules/Retribusi/Frontend/TariffController.php index a61a4db..d3bdec0 100644 --- a/src/Modules/Retribusi/Frontend/TariffController.php +++ b/src/Modules/Retribusi/Frontend/TariffController.php @@ -12,17 +12,100 @@ use Psr\Http\Message\ServerRequestInterface; class TariffController { + private RetribusiReadService $readService; private RetribusiWriteService $writeService; private AuditService $auditService; public function __construct( + RetribusiReadService $readService, RetribusiWriteService $writeService, AuditService $auditService ) { + $this->readService = $readService; $this->writeService = $writeService; $this->auditService = $auditService; } + public function getTariffs( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + $queryParams = $request->getQueryParams(); + [$page, $limit] = Validator::validatePagination($queryParams); + + $locationCode = $queryParams['location_code'] ?? null; + if ($locationCode !== null && !is_string($locationCode)) { + $locationCode = null; + } + + $gateCode = $queryParams['gate_code'] ?? null; + if ($gateCode !== null && !is_string($gateCode)) { + $gateCode = null; + } + + $data = $this->readService->getTariffs($page, $limit, $locationCode, $gateCode); + $total = $this->readService->getTariffsTotal($locationCode, $gateCode); + + return ResponseHelper::json( + $response, + [ + 'success' => true, + 'data' => $data, + 'meta' => [ + 'page' => $page, + 'limit' => $limit, + 'total' => $total, + 'pages' => (int) ceil($total / $limit) + ], + 'timestamp' => time() + ] + ); + } + + public function getTariff( + ServerRequestInterface $request, + ResponseInterface $response, + array $args + ): ResponseInterface { + $locationCode = $args['location_code'] ?? null; + $gateCode = $args['gate_code'] ?? null; + $category = $args['category'] ?? null; + + if ($locationCode === null || !is_string($locationCode) || + $gateCode === null || !is_string($gateCode) || + $category === null || !is_string($category)) { + return ResponseHelper::json( + $response, + [ + 'error' => 'validation_error', + 'fields' => ['location_code' => 'Invalid location_code, gate_code, or category'] + ], + 422 + ); + } + + $data = $this->writeService->getTariff($locationCode, $gateCode, $category); + if ($data === null) { + return ResponseHelper::json( + $response, + [ + 'error' => 'not_found', + 'message' => 'Tariff not found' + ], + 404 + ); + } + + return ResponseHelper::json( + $response, + [ + 'success' => true, + 'data' => $data, + 'timestamp' => time() + ] + ); + } + public function createTariff( ServerRequestInterface $request, ResponseInterface $response diff --git a/src/Modules/Retribusi/Realtime/RealtimeController.php b/src/Modules/Retribusi/Realtime/RealtimeController.php index d5ae5c9..d36d2ba 100644 --- a/src/Modules/Retribusi/Realtime/RealtimeController.php +++ b/src/Modules/Retribusi/Realtime/RealtimeController.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Modules\Retribusi\Realtime; +use App\Support\ResponseHelper; +use App\Support\Validator; use PDOException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -200,5 +202,171 @@ class RealtimeController ); } } + + /** + * Get entry events list + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + */ + public function getEntryEvents( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + $queryParams = $request->getQueryParams(); + [$page, $limit] = Validator::validatePagination($queryParams); + + $locationCode = $queryParams['location_code'] ?? null; + if ($locationCode !== null && !is_string($locationCode)) { + $locationCode = null; + } + + $gateCode = $queryParams['gate_code'] ?? null; + if ($gateCode !== null && !is_string($gateCode)) { + $gateCode = null; + } + + $category = $queryParams['category'] ?? null; + if ($category !== null && !is_string($category)) { + $category = null; + } + + $startDate = $queryParams['start_date'] ?? null; + if ($startDate !== null && !is_string($startDate)) { + $startDate = null; + } + + $endDate = $queryParams['end_date'] ?? null; + if ($endDate !== null && !is_string($endDate)) { + $endDate = null; + } + + try { + $data = $this->service->getEntryEvents( + $page, + $limit, + $locationCode, + $gateCode, + $category, + $startDate, + $endDate + ); + $total = $this->service->getEntryEventsTotal( + $locationCode, + $gateCode, + $category, + $startDate, + $endDate + ); + + return ResponseHelper::json( + $response, + [ + 'success' => true, + 'data' => $data, + 'meta' => [ + 'page' => $page, + 'limit' => $limit, + 'total' => $total, + 'pages' => (int) ceil($total / $limit) + ], + 'timestamp' => time() + ] + ); + } catch (PDOException $e) { + return ResponseHelper::json( + $response, + [ + 'error' => 'server_error', + 'message' => 'Database error occurred' + ], + 500 + ); + } + } + + /** + * Get realtime events list + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + */ + public function getRealtimeEvents( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + $queryParams = $request->getQueryParams(); + [$page, $limit] = Validator::validatePagination($queryParams); + + $locationCode = $queryParams['location_code'] ?? null; + if ($locationCode !== null && !is_string($locationCode)) { + $locationCode = null; + } + + $gateCode = $queryParams['gate_code'] ?? null; + if ($gateCode !== null && !is_string($gateCode)) { + $gateCode = null; + } + + $category = $queryParams['category'] ?? null; + if ($category !== null && !is_string($category)) { + $category = null; + } + + $startDate = $queryParams['start_date'] ?? null; + if ($startDate !== null && !is_string($startDate)) { + $startDate = null; + } + + $endDate = $queryParams['end_date'] ?? null; + if ($endDate !== null && !is_string($endDate)) { + $endDate = null; + } + + try { + $data = $this->service->getRealtimeEvents( + $page, + $limit, + $locationCode, + $gateCode, + $category, + $startDate, + $endDate + ); + $total = $this->service->getRealtimeEventsTotal( + $locationCode, + $gateCode, + $category, + $startDate, + $endDate + ); + + return ResponseHelper::json( + $response, + [ + 'success' => true, + 'data' => $data, + 'meta' => [ + 'page' => $page, + 'limit' => $limit, + 'total' => $total, + 'pages' => (int) ceil($total / $limit) + ], + 'timestamp' => time() + ] + ); + } catch (PDOException $e) { + return ResponseHelper::json( + $response, + [ + 'error' => 'server_error', + 'message' => 'Database error occurred' + ], + 500 + ); + } + } } diff --git a/src/Modules/Retribusi/Realtime/RealtimeRoutes.php b/src/Modules/Retribusi/Realtime/RealtimeRoutes.php index ac93e82..c1c4ef8 100644 --- a/src/Modules/Retribusi/Realtime/RealtimeRoutes.php +++ b/src/Modules/Retribusi/Realtime/RealtimeRoutes.php @@ -46,6 +46,7 @@ class RealtimeRoutes $v1Group->group('/realtime', function ($realtimeGroup) use ($realtimeController) { $realtimeGroup->get('/stream', [$realtimeController, 'stream']); $realtimeGroup->get('/snapshot', [$realtimeController, 'getSnapshot']); + $realtimeGroup->get('/events', [$realtimeController, 'getRealtimeEvents']); })->add($jwtMiddleware); }); }); diff --git a/src/Modules/Retribusi/Realtime/RealtimeService.php b/src/Modules/Retribusi/Realtime/RealtimeService.php index f21420f..d6cacbf 100644 --- a/src/Modules/Retribusi/Realtime/RealtimeService.php +++ b/src/Modules/Retribusi/Realtime/RealtimeService.php @@ -162,5 +162,265 @@ class RealtimeService 'by_category' => $byCategoryFormatted ]; } + + /** + * Get entry events list with pagination and optional filters + * + * @param int $page + * @param int $limit + * @param string|null $locationCode Optional filter by location + * @param string|null $gateCode Optional filter by gate + * @param string|null $category Optional filter by category + * @param string|null $startDate Optional start date (YYYY-MM-DD) + * @param string|null $endDate Optional end date (YYYY-MM-DD) + * @return array + * @throws PDOException + */ + public function getEntryEvents( + int $page, + int $limit, + ?string $locationCode = null, + ?string $gateCode = null, + ?string $category = null, + ?string $startDate = null, + ?string $endDate = null + ): array { + $offset = ($page - 1) * $limit; + $where = []; + $params = []; + + if ($locationCode !== null) { + $where[] = 'location_code = ?'; + $params[] = $locationCode; + } + + if ($gateCode !== null) { + $where[] = 'gate_code = ?'; + $params[] = $gateCode; + } + + if ($category !== null) { + $where[] = 'category = ?'; + $params[] = $category; + } + + if ($startDate !== null) { + $where[] = 'DATE(event_time) >= ?'; + $params[] = $startDate; + } + + if ($endDate !== null) { + $where[] = 'DATE(event_time) <= ?'; + $params[] = $endDate; + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + $sql = "SELECT id, location_code, gate_code, category, event_time, source_ip, created_at + FROM entry_events + {$whereClause} + ORDER BY event_time DESC, id DESC + LIMIT ? OFFSET ?"; + + $stmt = $this->db->prepare($sql); + $paramIndex = 1; + foreach ($params as $param) { + $stmt->bindValue($paramIndex++, $param, PDO::PARAM_STR); + } + $stmt->bindValue($paramIndex++, $limit, PDO::PARAM_INT); + $stmt->bindValue($paramIndex, $offset, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } + + /** + * Get total count of entry events + * + * @param string|null $locationCode Optional filter by location + * @param string|null $gateCode Optional filter by gate + * @param string|null $category Optional filter by category + * @param string|null $startDate Optional start date + * @param string|null $endDate Optional end date + * @return int + * @throws PDOException + */ + public function getEntryEventsTotal( + ?string $locationCode = null, + ?string $gateCode = null, + ?string $category = null, + ?string $startDate = null, + ?string $endDate = null + ): int { + $where = []; + $params = []; + + if ($locationCode !== null) { + $where[] = 'location_code = ?'; + $params[] = $locationCode; + } + + if ($gateCode !== null) { + $where[] = 'gate_code = ?'; + $params[] = $gateCode; + } + + if ($category !== null) { + $where[] = 'category = ?'; + $params[] = $category; + } + + if ($startDate !== null) { + $where[] = 'DATE(event_time) >= ?'; + $params[] = $startDate; + } + + if ($endDate !== null) { + $where[] = 'DATE(event_time) <= ?'; + $params[] = $endDate; + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT COUNT(*) FROM entry_events {$whereClause}"; + + if (!empty($params)) { + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + } else { + $stmt = $this->db->query($sql); + } + + return (int) $stmt->fetchColumn(); + } + + /** + * Get realtime events list with pagination and optional filters + * + * @param int $page + * @param int $limit + * @param string|null $locationCode Optional filter by location + * @param string|null $gateCode Optional filter by gate + * @param string|null $category Optional filter by category + * @param string|null $startDate Optional start date (YYYY-MM-DD) + * @param string|null $endDate Optional end date (YYYY-MM-DD) + * @return array + * @throws PDOException + */ + public function getRealtimeEvents( + int $page, + int $limit, + ?string $locationCode = null, + ?string $gateCode = null, + ?string $category = null, + ?string $startDate = null, + ?string $endDate = null + ): array { + $offset = ($page - 1) * $limit; + $where = []; + $params = []; + + if ($locationCode !== null) { + $where[] = 'location_code = ?'; + $params[] = $locationCode; + } + + if ($gateCode !== null) { + $where[] = 'gate_code = ?'; + $params[] = $gateCode; + } + + if ($category !== null) { + $where[] = 'category = ?'; + $params[] = $category; + } + + if ($startDate !== null) { + $where[] = 'DATE(created_at) >= ?'; + $params[] = $startDate; + } + + if ($endDate !== null) { + $where[] = 'DATE(created_at) <= ?'; + $params[] = $endDate; + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + $sql = "SELECT id, location_code, gate_code, category, event_time, total_count_delta, created_at + FROM realtime_events + {$whereClause} + ORDER BY created_at DESC, id DESC + LIMIT ? OFFSET ?"; + + $stmt = $this->db->prepare($sql); + $paramIndex = 1; + foreach ($params as $param) { + $stmt->bindValue($paramIndex++, $param, PDO::PARAM_STR); + } + $stmt->bindValue($paramIndex++, $limit, PDO::PARAM_INT); + $stmt->bindValue($paramIndex, $offset, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } + + /** + * Get total count of realtime events + * + * @param string|null $locationCode Optional filter by location + * @param string|null $gateCode Optional filter by gate + * @param string|null $category Optional filter by category + * @param string|null $startDate Optional start date + * @param string|null $endDate Optional end date + * @return int + * @throws PDOException + */ + public function getRealtimeEventsTotal( + ?string $locationCode = null, + ?string $gateCode = null, + ?string $category = null, + ?string $startDate = null, + ?string $endDate = null + ): int { + $where = []; + $params = []; + + if ($locationCode !== null) { + $where[] = 'location_code = ?'; + $params[] = $locationCode; + } + + if ($gateCode !== null) { + $where[] = 'gate_code = ?'; + $params[] = $gateCode; + } + + if ($category !== null) { + $where[] = 'category = ?'; + $params[] = $category; + } + + if ($startDate !== null) { + $where[] = 'DATE(created_at) >= ?'; + $params[] = $startDate; + } + + if ($endDate !== null) { + $where[] = 'DATE(created_at) <= ?'; + $params[] = $endDate; + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT COUNT(*) FROM realtime_events {$whereClause}"; + + if (!empty($params)) { + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + } else { + $stmt = $this->db->query($sql); + } + + return (int) $stmt->fetchColumn(); + } } diff --git a/src/Modules/Retribusi/RetribusiRoutes.php b/src/Modules/Retribusi/RetribusiRoutes.php index 15b659b..fa73852 100644 --- a/src/Modules/Retribusi/RetribusiRoutes.php +++ b/src/Modules/Retribusi/RetribusiRoutes.php @@ -8,13 +8,16 @@ use App\Config\AppConfig; use App\Middleware\ApiKeyMiddleware; use App\Middleware\JwtMiddleware; use App\Middleware\RoleMiddleware; +use App\Modules\Retribusi\Frontend\AuditController; use App\Modules\Retribusi\Frontend\AuditService; +use App\Modules\Retribusi\Frontend\EntryEventsController; use App\Modules\Retribusi\Frontend\GateController; use App\Modules\Retribusi\Frontend\LocationController; use App\Modules\Retribusi\Frontend\RetribusiReadService; use App\Modules\Retribusi\Frontend\RetribusiWriteService; use App\Modules\Retribusi\Frontend\StreamController; use App\Modules\Retribusi\Frontend\TariffController; +use App\Modules\Retribusi\Realtime\RealtimeService; use App\Modules\Retribusi\Ingest\IngestController; use App\Modules\Retribusi\Ingest\IngestService; use App\Support\Database; @@ -62,7 +65,11 @@ class RetribusiRoutes $gateController = new GateController($readService, $writeService, $auditService); $locationController = new LocationController($readService, $writeService, $auditService); $streamController = new StreamController($readService); - $tariffController = new TariffController($writeService, $auditService); + $tariffController = new TariffController($readService, $writeService, $auditService); + $auditController = new AuditController($auditService); + + $realtimeService = new RealtimeService($db); + $entryEventsController = new EntryEventsController($realtimeService); // Register routes $app->group('/retribusi', function ($group) use ( @@ -74,7 +81,9 @@ class RetribusiRoutes $gateController, $locationController, $streamController, - $tariffController + $tariffController, + $auditController, + $entryEventsController ) { $group->group('/v1', function ($v1Group) use ( $apiKeyMiddleware, @@ -85,7 +94,9 @@ class RetribusiRoutes $gateController, $locationController, $streamController, - $tariffController + $tariffController, + $auditController, + $entryEventsController ) { // Ingest routes (with API key middleware) $v1Group->post('/ingest', [$ingestController, 'ingest']) @@ -98,12 +109,20 @@ class RetribusiRoutes $gateController, $locationController, $streamController, - $tariffController + $tariffController, + $auditController, + $entryEventsController ) { // Read routes (viewer, operator, admin) $frontendGroup->get('/gates', [$gateController, 'getGates']); + $frontendGroup->get('/gates/{location_code}/{gate_code}', [$gateController, 'getGate']); $frontendGroup->get('/locations', [$locationController, 'getLocations']); + $frontendGroup->get('/locations/{code}', [$locationController, 'getLocation']); $frontendGroup->get('/streams', [$streamController, 'getStreams']); + $frontendGroup->get('/tariffs', [$tariffController, 'getTariffs']); + $frontendGroup->get('/tariffs/{location_code}/{gate_code}/{category}', [$tariffController, 'getTariff']); + $frontendGroup->get('/audit-logs', [$auditController, 'getAuditLogs']); + $frontendGroup->get('/entry-events', [$entryEventsController, 'getEntryEvents']); // Write routes (operator, admin) $frontendGroup->post('/locations', [$locationController, 'createLocation']) diff --git a/src/Support/Validator.php b/src/Support/Validator.php index 23b7f2c..7f9e976 100644 --- a/src/Support/Validator.php +++ b/src/Support/Validator.php @@ -216,6 +216,18 @@ class Validator $errors['direction'] = 'Field is required'; } + // camera: optional, but if provided must be valid string + // Supports various formats: HLS (.m3u8), RTSP, HTTP, camera ID, etc. + if (isset($data['camera'])) { + if (!is_string($data['camera'])) { + $errors['camera'] = 'Must be a string'; + } elseif (strlen($data['camera']) > 500) { + $errors['camera'] = 'Must not exceed 500 characters'; + } elseif (trim($data['camera']) === '') { + $errors['camera'] = 'Cannot be empty string (use null to remove)'; + } + } + // is_active: optional, but if provided must be 0 or 1 if (isset($data['is_active'])) { if (!in_array($data['is_active'], [0, 1], true)) {