feat: tambah endpoint frontend dan field camera di gate

- Tambah endpoint GET /tariffs (list tariffs)
- Tambah endpoint GET /locations/{code} (detail location)
- Tambah endpoint GET /gates/{location_code}/{gate_code} (detail gate)
- Tambah endpoint GET /tariffs/{location_code}/{gate_code}/{category} (detail tariff)
- Tambah endpoint GET /audit-logs (audit trail history)
- Tambah endpoint GET /entry-events (raw entry events)
- Tambah endpoint GET /realtime/events (realtime events list)
- Tambah field camera di gates (support HLS, RTSP, HTTP streaming)
- Migration 004: add camera column to gates table
- Update validasi dan service untuk support camera field
This commit is contained in:
mwpn
2025-12-18 06:36:49 +07:00
parent 1d5511ccbc
commit 4d36f02f32
15 changed files with 1100 additions and 9 deletions

View File

@@ -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);

View File

@@ -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

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Frontend;
use App\Support\ResponseHelper;
use App\Support\Validator;
use PDOException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class AuditController
{
private AuditService $auditService;
public function __construct(AuditService $auditService)
{
$this->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
);
}
}
}

View File

@@ -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
*

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Frontend;
use App\Modules\Retribusi\Realtime\RealtimeService;
use App\Support\ResponseHelper;
use App\Support\Validator;
use PDOException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class EntryEventsController
{
private RealtimeService $realtimeService;
public function __construct(RealtimeService $realtimeService)
{
$this->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
);
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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'];

View File

@@ -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

View File

@@ -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
);
}
}
}

View File

@@ -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);
});
});

View File

@@ -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();
}
}

View File

@@ -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'])

View File

@@ -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)) {