Initial commit: Slim Framework 4 API Retribusi dengan modular architecture

This commit is contained in:
mwpn
2025-12-17 10:43:03 +07:00
commit 39f23388a7
45 changed files with 5439 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Bootstrap;
use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Middleware\BodyParsingMiddleware;
class AppBootstrap
{
/**
* Create and configure Slim App instance
*
* @return App
*/
public static function create(): App
{
$app = AppFactory::create();
// Add body parsing middleware
$app->addBodyParsingMiddleware();
// Add routing middleware
$app->addRoutingMiddleware();
// Add error middleware
$app->addErrorMiddleware(true, true, true);
return $app;
}
}

34
src/Bootstrap/app.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Bootstrap;
use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Middleware\BodyParsingMiddleware;
class AppBootstrap
{
/**
* Create and configure Slim App instance
*
* @return App
*/
public static function create(): App
{
$app = AppFactory::create();
// Add body parsing middleware
$app->addBodyParsingMiddleware();
// Add routing middleware
$app->addRoutingMiddleware();
// Add error middleware
$app->addErrorMiddleware(true, true, true);
return $app;
}
}

41
src/Config/AppConfig.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Config;
use Dotenv\Dotenv;
class AppConfig
{
/**
* Load environment variables from .env file
* Safe if .env file doesn't exist
*
* @param string $rootPath
* @return void
*/
public static function loadEnv(string $rootPath): void
{
$envPath = $rootPath . DIRECTORY_SEPARATOR . '.env';
if (file_exists($envPath)) {
$dotenv = Dotenv::createImmutable($rootPath);
$dotenv->load();
}
}
/**
* Get environment variable with default value
*
* @param string $key
* @param string|null $default
* @return string|null
*/
public static function get(string $key, ?string $default = null): ?string
{
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
return $value !== false ? (string) $value : $default;
}
}

28
src/Config/app.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Config;
use Dotenv\Dotenv;
class AppConfig
{
/**
* Load environment variables from .env file
* Safe if .env file doesn't exist
*
* @param string $rootPath
* @return void
*/
public static function loadEnv(string $rootPath): void
{
$envPath = $rootPath . DIRECTORY_SEPARATOR . '.env';
if (file_exists($envPath)) {
$dotenv = Dotenv::createImmutable($rootPath);
$dotenv->load();
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Support\ResponseHelper;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Factory\ResponseFactory;
class ApiKeyMiddleware implements MiddlewareInterface
{
private string $apiKey;
public function __construct(string $apiKey)
{
$this->apiKey = $apiKey;
}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$headers = $request->getHeaders();
$providedKey = null;
// Case-insensitive check for X-API-KEY header
foreach ($headers as $name => $values) {
if (strtolower($name) === 'x-api-key') {
$providedKey = $values[0] ?? null;
break;
}
}
// Check if API key is provided and matches
if (empty($providedKey) || $providedKey !== $this->apiKey) {
$responseFactory = new ResponseFactory();
$response = $responseFactory->createResponse();
return ResponseHelper::json(
$response,
['error' => 'unauthorized'],
401
);
}
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Config\AppConfig;
use App\Support\Jwt;
use App\Support\ResponseHelper;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RuntimeException;
use Slim\Psr7\Factory\ResponseFactory;
class JwtMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
// Get Authorization header
$authHeader = $request->getHeaderLine('Authorization');
if (empty($authHeader)) {
$responseFactory = new ResponseFactory();
$response = $responseFactory->createResponse();
return ResponseHelper::json(
$response,
['error' => 'unauthorized', 'message' => 'Missing authorization header'],
401
);
}
// Extract Bearer token
if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
$responseFactory = new ResponseFactory();
$response = $responseFactory->createResponse();
return ResponseHelper::json(
$response,
['error' => 'unauthorized', 'message' => 'Invalid authorization format'],
401
);
}
$token = $matches[1];
// Get JWT secret
$jwtSecret = AppConfig::get('JWT_SECRET', '');
if (empty($jwtSecret)) {
$responseFactory = new ResponseFactory();
$response = $responseFactory->createResponse();
return ResponseHelper::json(
$response,
['error' => 'server_error', 'message' => 'JWT configuration error'],
500
);
}
try {
// Decode and validate token
$payload = Jwt::decode($token, $jwtSecret);
// Inject user context to request attributes
$request = $request->withAttribute('user_id', (int) $payload['sub']);
$request = $request->withAttribute('username', $payload['username'] ?? '');
$request = $request->withAttribute('role', $payload['role'] ?? '');
return $handler->handle($request);
} catch (InvalidArgumentException | RuntimeException $e) {
$responseFactory = new ResponseFactory();
$response = $responseFactory->createResponse();
return ResponseHelper::json(
$response,
['error' => 'unauthorized', 'message' => 'Invalid or expired token'],
401
);
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Support\ResponseHelper;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Factory\ResponseFactory;
class RoleMiddleware implements MiddlewareInterface
{
private array $allowedRoles;
public function __construct(array $allowedRoles)
{
$this->allowedRoles = $allowedRoles;
}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$userRole = $request->getAttribute('role', '');
if (empty($userRole) || !in_array($userRole, $this->allowedRoles, true)) {
$responseFactory = new ResponseFactory();
$response = $responseFactory->createResponse();
return ResponseHelper::json(
$response,
[
'error' => 'forbidden',
'message' => 'Insufficient permissions'
],
403
);
}
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Support\ResponseHelper;
use PDOException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class AuthController
{
private AuthService $service;
public function __construct(AuthService $service)
{
$this->service = $service;
}
public function login(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$body = $request->getParsedBody();
// Validate request body
if (!is_array($body)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['body' => 'Invalid JSON body']
],
422
);
}
// Validate required fields
$username = $body['username'] ?? null;
$password = $body['password'] ?? null;
if (!is_string($username) || empty($username)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['username' => 'Field is required']
],
422
);
}
if (!is_string($password) || empty($password)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['password' => 'Field is required']
],
422
);
}
try {
$result = $this->service->login($username, $password);
if (!$result['success']) {
$statusCode = match ($result['error']) {
'forbidden' => 403,
default => 401
};
return ResponseHelper::json(
$response,
['error' => $result['error']],
$statusCode
);
}
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $result['data'],
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Config\AppConfig;
use App\Modules\Auth\AuthController;
use App\Modules\Auth\AuthService;
use App\Support\Database;
use PDO;
use Slim\App;
class AuthRoutes
{
/**
* Register auth routes
*
* @param App $app
* @return void
*/
public static function register(App $app): void
{
// Get database connection
$dbHost = AppConfig::get('DB_HOST', 'localhost');
$dbName = AppConfig::get('DB_NAME', '');
$dbUser = AppConfig::get('DB_USER', '');
$dbPass = AppConfig::get('DB_PASS', '');
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
// Get JWT config
$jwtSecret = AppConfig::get('JWT_SECRET', '');
$jwtTtl = (int) (AppConfig::get('JWT_TTL_SECONDS', '3600') ?: 3600);
$jwtIssuer = AppConfig::get('JWT_ISSUER', 'api-btekno');
// Initialize service and controller
$authService = new AuthService($db, $jwtSecret, $jwtTtl, $jwtIssuer);
$authController = new AuthController($authService);
// Register routes
$app->group('/auth', function ($group) use ($authController) {
$group->group('/v1', function ($v1Group) use ($authController) {
$v1Group->post('/login', [$authController, 'login']);
});
});
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Support\Jwt;
use PDO;
use PDOException;
class AuthService
{
private PDO $db;
private string $jwtSecret;
private int $jwtTtl;
private string $jwtIssuer;
public function __construct(
PDO $db,
string $jwtSecret,
int $jwtTtl,
string $jwtIssuer
) {
$this->db = $db;
$this->jwtSecret = $jwtSecret;
$this->jwtTtl = $jwtTtl;
$this->jwtIssuer = $jwtIssuer;
}
/**
* Authenticate user and generate JWT token
*
* @param string $username
* @param string $password
* @return array ['success' => bool, 'data' => array|null, 'error' => string|null]
* @throws PDOException
*/
public function login(string $username, string $password): array
{
// Find user by username
$stmt = $this->db->prepare(
'SELECT id, username, password, role, is_active
FROM users
WHERE username = ?
LIMIT 1'
);
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user === false) {
return [
'success' => false,
'data' => null,
'error' => 'unauthorized'
];
}
// Verify password
if (!password_verify($password, $user['password'])) {
return [
'success' => false,
'data' => null,
'error' => 'unauthorized'
];
}
// Check if user is active
if (!$user['is_active']) {
return [
'success' => false,
'data' => null,
'error' => 'forbidden'
];
}
// Generate JWT token
$token = Jwt::encode(
[
'sub' => (string) $user['id'],
'username' => $user['username'],
'role' => $user['role']
],
$this->jwtSecret,
$this->jwtTtl,
$this->jwtIssuer
);
return [
'success' => true,
'data' => [
'token' => $token,
'expires_in' => $this->jwtTtl,
'user' => [
'id' => (int) $user['id'],
'username' => $user['username'],
'role' => $user['role']
]
],
'error' => null
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Modules\Health;
use App\Support\ResponseHelper;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\App;
class HealthRoutes
{
/**
* Register health check routes
*
* @param App $app
* @return void
*/
public static function register(App $app): void
{
$app->get('/health', function (
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$data = [
'status' => 'ok',
'time' => time()
];
return ResponseHelper::json($response, $data);
});
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Modules\Health;
use App\Support\ResponseHelper;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\App;
class HealthRoutes
{
/**
* Register health check routes
*
* @param App $app
* @return void
*/
public static function register(App $app): void
{
$app->get('/health', function (
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$data = [
'status' => 'ok',
'time' => time()
];
return ResponseHelper::json($response, $data);
});
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Dashboard;
use App\Support\ResponseHelper;
use PDOException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class DashboardController
{
private DashboardService $service;
public function __construct(DashboardService $service)
{
$this->service = $service;
}
/**
* Get daily chart data
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*/
public function getDailyChart(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$queryParams = $request->getQueryParams();
$startDate = $queryParams['start_date'] ?? null;
$endDate = $queryParams['end_date'] ?? null;
if ($startDate === null || !is_string($startDate)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['start_date' => 'Query parameter start_date is required (Y-m-d format)']
],
422
);
}
if ($endDate === null || !is_string($endDate)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['end_date' => 'Query parameter end_date is required (Y-m-d format)']
],
422
);
}
// Validate date format
$startDateTime = \DateTime::createFromFormat('Y-m-d', $startDate);
if ($startDateTime === false || $startDateTime->format('Y-m-d') !== $startDate) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['start_date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
],
422
);
}
$endDateTime = \DateTime::createFromFormat('Y-m-d', $endDate);
if ($endDateTime === false || $endDateTime->format('Y-m-d') !== $endDate) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['end_date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
],
422
);
}
// Validate date range
if ($startDate > $endDate) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['start_date' => 'start_date must be less than or equal to end_date']
],
422
);
}
$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;
}
try {
$data = $this->service->getDailyChart($startDate, $endDate, $locationCode, $gateCode);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
/**
* Get chart data by category
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*/
public function getByCategoryChart(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$queryParams = $request->getQueryParams();
$date = $queryParams['date'] ?? null;
if ($date === null || !is_string($date)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Query parameter date is required (Y-m-d format)']
],
422
);
}
// Validate date format
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
],
422
);
}
$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;
}
try {
$data = $this->service->getByCategoryChart($date, $locationCode, $gateCode);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
/**
* Get summary statistics
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*/
public function getSummary(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$queryParams = $request->getQueryParams();
$date = $queryParams['date'] ?? null;
if ($date === null || !is_string($date)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Query parameter date is required (Y-m-d format)']
],
422
);
}
// Validate date format
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
],
422
);
}
$locationCode = $queryParams['location_code'] ?? null;
if ($locationCode !== null && !is_string($locationCode)) {
$locationCode = null;
}
try {
$data = $this->service->getSummary($date, $locationCode);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Dashboard;
use App\Config\AppConfig;
use App\Middleware\JwtMiddleware;
use App\Support\Database;
use Slim\App;
class DashboardRoutes
{
/**
* Register dashboard routes
*
* @param App $app
* @return void
*/
public static function register(App $app): void
{
// JWT middleware (read-only, semua role boleh akses)
$jwtMiddleware = new JwtMiddleware();
// Get database connection
$dbHost = AppConfig::get('DB_HOST', 'localhost');
$dbName = AppConfig::get('DB_NAME', '');
$dbUser = AppConfig::get('DB_USER', '');
$dbPass = AppConfig::get('DB_PASS', '');
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
// Initialize service and controller
$dashboardService = new DashboardService($db);
$dashboardController = new DashboardController($dashboardService);
// Register routes
$app->group('/retribusi', function ($group) use (
$jwtMiddleware,
$dashboardController
) {
$group->group('/v1', function ($v1Group) use (
$jwtMiddleware,
$dashboardController
) {
$v1Group->group('/dashboard', function ($dashboardGroup) use ($dashboardController) {
$dashboardGroup->get('/daily', [$dashboardController, 'getDailyChart']);
$dashboardGroup->get('/by-category', [$dashboardController, 'getByCategoryChart']);
$dashboardGroup->get('/summary', [$dashboardController, 'getSummary']);
})->add($jwtMiddleware);
});
});
}
}

View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Dashboard;
use PDO;
use PDOException;
class DashboardService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Get daily chart data (line chart)
*
* @param string $startDate
* @param string $endDate
* @param string|null $locationCode
* @param string|null $gateCode
* @return array
* @throws PDOException
*/
public function getDailyChart(
string $startDate,
string $endDate,
?string $locationCode = null,
?string $gateCode = null
): array {
$sql = "
SELECT
summary_date,
SUM(total_count) as total_count,
SUM(total_amount) as total_amount
FROM daily_summary
WHERE summary_date >= ? AND summary_date <= ?
";
$params = [$startDate, $endDate];
if ($locationCode !== null) {
$sql .= " AND location_code = ?";
$params[] = $locationCode;
}
if ($gateCode !== null) {
$sql .= " AND gate_code = ?";
$params[] = $gateCode;
}
$sql .= " GROUP BY summary_date ORDER BY summary_date ASC";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$results = $stmt->fetchAll();
$labels = [];
$totalCounts = [];
$totalAmounts = [];
foreach ($results as $row) {
$labels[] = $row['summary_date'];
$totalCounts[] = (int) $row['total_count'];
$totalAmounts[] = (int) $row['total_amount'];
}
return [
'labels' => $labels,
'series' => [
'total_count' => $totalCounts,
'total_amount' => $totalAmounts
]
];
}
/**
* Get chart data by category (bar/donut chart)
*
* @param string $date
* @param string|null $locationCode
* @param string|null $gateCode
* @return array
* @throws PDOException
*/
public function getByCategoryChart(
string $date,
?string $locationCode = null,
?string $gateCode = null
): array {
$sql = "
SELECT
category,
SUM(total_count) as total_count,
SUM(total_amount) as total_amount
FROM daily_summary
WHERE summary_date = ?
";
$params = [$date];
if ($locationCode !== null) {
$sql .= " AND location_code = ?";
$params[] = $locationCode;
}
if ($gateCode !== null) {
$sql .= " AND gate_code = ?";
$params[] = $gateCode;
}
$sql .= " GROUP BY category ORDER BY category ASC";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$results = $stmt->fetchAll();
$labels = [];
$totalCounts = [];
$totalAmounts = [];
foreach ($results as $row) {
$labels[] = $row['category'];
$totalCounts[] = (int) $row['total_count'];
$totalAmounts[] = (int) $row['total_amount'];
}
return [
'labels' => $labels,
'series' => [
'total_count' => $totalCounts,
'total_amount' => $totalAmounts
]
];
}
/**
* Get summary statistics (stat cards)
*
* @param string $date
* @param string|null $locationCode
* @return array
* @throws PDOException
*/
public function getSummary(string $date, ?string $locationCode = null): array
{
// Get total count and amount from daily_summary
$sql = "
SELECT
SUM(total_count) as total_count,
SUM(total_amount) as total_amount
FROM daily_summary
WHERE summary_date = ?
";
$params = [$date];
if ($locationCode !== null) {
$sql .= " AND location_code = ?";
$params[] = $locationCode;
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$summary = $stmt->fetch();
$totalCount = (int) ($summary['total_count'] ?? 0);
$totalAmount = (int) ($summary['total_amount'] ?? 0);
// Get active gates count
$gatesSql = "
SELECT COUNT(DISTINCT gate_code) as active_gates
FROM daily_summary
WHERE summary_date = ?
";
$gatesParams = [$date];
if ($locationCode !== null) {
$gatesSql .= " AND location_code = ?";
$gatesParams[] = $locationCode;
}
$gatesStmt = $this->db->prepare($gatesSql);
$gatesStmt->execute($gatesParams);
$gatesResult = $gatesStmt->fetch();
$activeGates = (int) ($gatesResult['active_gates'] ?? 0);
// Get active locations count
$locationsSql = "
SELECT COUNT(DISTINCT location_code) as active_locations
FROM daily_summary
WHERE summary_date = ?
";
$locationsParams = [$date];
if ($locationCode !== null) {
$locationsSql .= " AND location_code = ?";
$locationsParams[] = $locationCode;
}
$locationsStmt = $this->db->prepare($locationsSql);
$locationsStmt->execute($locationsParams);
$locationsResult = $locationsStmt->fetch();
$activeLocations = (int) ($locationsResult['active_locations'] ?? 0);
return [
'total_count' => $totalCount,
'total_amount' => $totalAmount,
'active_gates' => $activeGates,
'active_locations' => $activeLocations
];
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Frontend;
use PDO;
use PDOException;
class AuditService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Log audit entry
*
* @param int $actorUserId
* @param string $actorUsername
* @param string $actorRole
* @param string $action
* @param string $entity
* @param string $entityKey
* @param array|null $beforeData
* @param array|null $afterData
* @param string $ipAddress
* @param string|null $userAgent
* @return void
* @throws PDOException
*/
public function log(
int $actorUserId,
string $actorUsername,
string $actorRole,
string $action,
string $entity,
string $entityKey,
?array $beforeData,
?array $afterData,
string $ipAddress,
?string $userAgent = null
): void {
$stmt = $this->db->prepare(
'INSERT INTO audit_logs
(actor_user_id, actor_username, actor_role, action, entity, entity_key,
before_json, after_json, ip_address, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'
);
$beforeJson = $beforeData !== null ? json_encode($beforeData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
$afterJson = $afterData !== null ? json_encode($afterData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
$stmt->execute([
$actorUserId,
$actorUsername,
$actorRole,
$action,
$entity,
$entityKey,
$beforeJson,
$afterJson,
$ipAddress,
$userAgent
]);
}
/**
* Get client IP address from request
*
* @param array $serverParams
* @return string
*/
public static function getClientIp(array $serverParams): string
{
// Check for forwarded IP (behind proxy/load balancer)
if (isset($serverParams['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $serverParams['HTTP_X_FORWARDED_FOR']);
return trim($ips[0]);
}
if (isset($serverParams['HTTP_X_REAL_IP'])) {
return $serverParams['HTTP_X_REAL_IP'];
}
return $serverParams['REMOTE_ADDR'] ?? '0.0.0.0';
}
}

View File

@@ -0,0 +1,364 @@
<?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 GateController
{
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 getGates(
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;
}
$data = $this->readService->getGates($page, $limit, $locationCode);
$total = $this->readService->getGatesTotal($locationCode);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'meta' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'pages' => (int) ceil($total / $limit)
],
'timestamp' => time()
]
);
}
public function createGate(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$body = $request->getParsedBody();
if (!is_array($body)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['body' => 'Invalid JSON body']
],
422
);
}
$errors = Validator::validateGate($body, false);
if (!empty($errors)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => $errors
],
422
);
}
try {
// Check if gate already exists
$existing = $this->writeService->getGate($body['location_code'], $body['gate_code']);
if ($existing !== null) {
return ResponseHelper::json(
$response,
[
'error' => 'conflict',
'message' => 'Gate with this location_code and gate_code already exists'
],
409
);
}
// Create gate
$data = $this->writeService->createGate($body);
// Audit log
$entityKey = $body['location_code'] . ':' . $body['gate_code'];
$serverParams = $request->getServerParams();
$this->auditService->log(
(int) $request->getAttribute('user_id'),
$request->getAttribute('username', ''),
$request->getAttribute('role', ''),
'create',
'gates',
$entityKey,
null,
$data,
AuditService::getClientIp($serverParams),
$serverParams['HTTP_USER_AGENT'] ?? null
);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'timestamp' => time()
],
201
);
} catch (PDOException $e) {
if ($e->getCode() === '23000') {
return ResponseHelper::json(
$response,
[
'error' => 'conflict',
'message' => 'Gate with this location_code and gate_code already exists'
],
409
);
}
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
public function updateGate(
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
);
}
$body = $request->getParsedBody();
if (!is_array($body)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['body' => 'Invalid JSON body']
],
422
);
}
// Prevent changing immutable fields
if (isset($body['location_code']) && $body['location_code'] !== $locationCode) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['location_code' => 'Location code is immutable']
],
422
);
}
if (isset($body['gate_code']) && $body['gate_code'] !== $gateCode) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['gate_code' => 'Gate code is immutable']
],
422
);
}
$errors = Validator::validateGate($body, true);
if (!empty($errors)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => $errors
],
422
);
}
try {
$before = $this->writeService->getGate($locationCode, $gateCode);
if ($before === null) {
return ResponseHelper::json(
$response,
[
'error' => 'not_found',
'message' => 'Gate not found'
],
404
);
}
$after = $this->writeService->updateGate($locationCode, $gateCode, $body);
if ($after === null) {
return ResponseHelper::json(
$response,
[
'error' => 'not_found',
'message' => 'Gate not found'
],
404
);
}
$entityKey = $locationCode . ':' . $gateCode;
$serverParams = $request->getServerParams();
$this->auditService->log(
(int) $request->getAttribute('user_id'),
$request->getAttribute('username', ''),
$request->getAttribute('role', ''),
'update',
'gates',
$entityKey,
$before,
$after,
AuditService::getClientIp($serverParams),
$serverParams['HTTP_USER_AGENT'] ?? null
);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $after,
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
public function deleteGate(
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
);
}
try {
$before = $this->writeService->getGate($locationCode, $gateCode);
if ($before === null) {
return ResponseHelper::json(
$response,
[
'error' => 'not_found',
'message' => 'Gate not found'
],
404
);
}
$deleted = $this->writeService->deleteGate($locationCode, $gateCode);
if (!$deleted) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Failed to delete gate'
],
500
);
}
$after = $this->writeService->getGate($locationCode, $gateCode);
$entityKey = $locationCode . ':' . $gateCode;
$serverParams = $request->getServerParams();
$this->auditService->log(
(int) $request->getAttribute('user_id'),
$request->getAttribute('username', ''),
$request->getAttribute('role', ''),
'delete',
'gates',
$entityKey,
$before,
$after,
AuditService::getClientIp($serverParams),
$serverParams['HTTP_USER_AGENT'] ?? null
);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => ['deleted' => true],
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
}

View File

@@ -0,0 +1,349 @@
<?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 LocationController
{
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 getLocations(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$queryParams = $request->getQueryParams();
[$page, $limit] = Validator::validatePagination($queryParams);
$data = $this->readService->getLocations($page, $limit);
$total = $this->readService->getLocationsTotal();
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'meta' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'pages' => (int) ceil($total / $limit)
],
'timestamp' => time()
]
);
}
public function createLocation(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$body = $request->getParsedBody();
if (!is_array($body)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['body' => 'Invalid JSON body']
],
422
);
}
$errors = Validator::validateLocation($body, false);
if (!empty($errors)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => $errors
],
422
);
}
try {
// Check if location already exists
$existing = $this->writeService->getLocation($body['code']);
if ($existing !== null) {
return ResponseHelper::json(
$response,
[
'error' => 'conflict',
'message' => 'Location with this code already exists'
],
409
);
}
// Create location
$data = $this->writeService->createLocation($body);
// Audit log
$serverParams = $request->getServerParams();
$this->auditService->log(
(int) $request->getAttribute('user_id'),
$request->getAttribute('username', ''),
$request->getAttribute('role', ''),
'create',
'locations',
$body['code'],
null,
$data,
AuditService::getClientIp($serverParams),
$serverParams['HTTP_USER_AGENT'] ?? null
);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'timestamp' => time()
],
201
);
} catch (PDOException $e) {
// Check for unique constraint violation
if ($e->getCode() === '23000') {
return ResponseHelper::json(
$response,
[
'error' => 'conflict',
'message' => 'Location with this code already exists'
],
409
);
}
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
public function updateLocation(
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
);
}
$body = $request->getParsedBody();
if (!is_array($body)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['body' => 'Invalid JSON body']
],
422
);
}
// Prevent changing code
if (isset($body['code']) && $body['code'] !== $code) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['code' => 'Code is immutable']
],
422
);
}
$errors = Validator::validateLocation($body, true);
if (!empty($errors)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => $errors
],
422
);
}
try {
// Check if location exists
$before = $this->writeService->getLocation($code);
if ($before === null) {
return ResponseHelper::json(
$response,
[
'error' => 'not_found',
'message' => 'Location not found'
],
404
);
}
// Update location
$after = $this->writeService->updateLocation($code, $body);
if ($after === null) {
return ResponseHelper::json(
$response,
[
'error' => 'not_found',
'message' => 'Location not found'
],
404
);
}
// Audit log
$serverParams = $request->getServerParams();
$this->auditService->log(
(int) $request->getAttribute('user_id'),
$request->getAttribute('username', ''),
$request->getAttribute('role', ''),
'update',
'locations',
$code,
$before,
$after,
AuditService::getClientIp($serverParams),
$serverParams['HTTP_USER_AGENT'] ?? null
);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $after,
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
public function deleteLocation(
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
);
}
try {
// Check if location exists
$before = $this->writeService->getLocation($code);
if ($before === null) {
return ResponseHelper::json(
$response,
[
'error' => 'not_found',
'message' => 'Location not found'
],
404
);
}
// Soft delete
$deleted = $this->writeService->deleteLocation($code);
if (!$deleted) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Failed to delete location'
],
500
);
}
// Get after state (is_active=0)
$after = $this->writeService->getLocation($code);
// Audit log
$serverParams = $request->getServerParams();
$this->auditService->log(
(int) $request->getAttribute('user_id'),
$request->getAttribute('username', ''),
$request->getAttribute('role', ''),
'delete',
'locations',
$code,
$before,
$after,
AuditService::getClientIp($serverParams),
$serverParams['HTTP_USER_AGENT'] ?? null
);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => ['deleted' => true],
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Frontend;
use App\Support\Database;
use PDO;
use PDOException;
class RetribusiReadService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Get locations list with pagination
*
* @param int $page
* @param int $limit
* @return array
* @throws PDOException
*/
public function getLocations(int $page, int $limit): array
{
$offset = ($page - 1) * $limit;
$stmt = $this->db->prepare(
'SELECT code, name, type, is_active
FROM locations
ORDER BY name ASC
LIMIT ? OFFSET ?'
);
$stmt->bindValue(1, $limit, PDO::PARAM_INT);
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
/**
* Get total count of locations
*
* @return int
* @throws PDOException
*/
public function getLocationsTotal(): int
{
$stmt = $this->db->query('SELECT COUNT(*) FROM locations');
return (int) $stmt->fetchColumn();
}
/**
* Get gates list with pagination and optional location filter
*
* @param int $page
* @param int $limit
* @param string|null $locationCode
* @return array
* @throws PDOException
*/
public function getGates(int $page, int $limit, ?string $locationCode = null): array
{
$offset = ($page - 1) * $limit;
if ($locationCode !== null) {
$stmt = $this->db->prepare(
'SELECT g.location_code, g.gate_code, g.name, g.direction, g.is_active,
l.name as location_name
FROM gates g
INNER JOIN locations l ON g.location_code = l.code
WHERE g.location_code = ?
ORDER BY g.location_code, g.gate_code ASC
LIMIT ? OFFSET ?'
);
$stmt->bindValue(1, $locationCode, PDO::PARAM_STR);
$stmt->bindValue(2, $limit, PDO::PARAM_INT);
$stmt->bindValue(3, $offset, PDO::PARAM_INT);
$stmt->execute();
} else {
$stmt = $this->db->prepare(
'SELECT g.location_code, g.gate_code, g.name, g.direction, g.is_active,
l.name as location_name
FROM gates g
INNER JOIN locations l ON g.location_code = l.code
ORDER BY g.location_code, g.gate_code ASC
LIMIT ? OFFSET ?'
);
$stmt->bindValue(1, $limit, PDO::PARAM_INT);
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
$stmt->execute();
}
return $stmt->fetchAll();
}
/**
* Get total count of gates
*
* @param string|null $locationCode
* @return int
* @throws PDOException
*/
public function getGatesTotal(?string $locationCode = null): int
{
if ($locationCode !== null) {
$stmt = $this->db->prepare('SELECT COUNT(*) FROM gates WHERE location_code = ?');
$stmt->execute([$locationCode]);
} else {
$stmt = $this->db->query('SELECT COUNT(*) FROM gates');
}
return (int) $stmt->fetchColumn();
}
/**
* Get streams list (alias for gates, sementara)
*
* @param int $page
* @param int $limit
* @return array
* @throws PDOException
*/
public function getStreams(int $page, int $limit): array
{
// Sementara stream = gate (alias)
return $this->getGates($page, $limit);
}
/**
* Get total count of streams
*
* @return int
* @throws PDOException
*/
public function getStreamsTotal(): int
{
return $this->getGatesTotal();
}
}

View File

@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Frontend;
use PDO;
use PDOException;
class RetribusiWriteService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Get location by code
*
* @param string $code
* @return array|null
* @throws PDOException
*/
public function getLocation(string $code): ?array
{
$stmt = $this->db->prepare(
'SELECT code, name, type, is_active
FROM locations
WHERE code = ?
LIMIT 1'
);
$stmt->execute([$code]);
$result = $stmt->fetch();
return $result !== false ? $result : null;
}
/**
* Create location
*
* @param array $data
* @return array
* @throws PDOException
*/
public function createLocation(array $data): array
{
$stmt = $this->db->prepare(
'INSERT INTO locations (code, name, type, is_active)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([
$data['code'],
$data['name'],
$data['type'],
$data['is_active']
]);
return $this->getLocation($data['code']);
}
/**
* Update location
*
* @param string $code
* @param array $data
* @return array|null
* @throws PDOException
*/
public function updateLocation(string $code, array $data): ?array
{
$updates = [];
$params = [];
if (isset($data['name'])) {
$updates[] = 'name = ?';
$params[] = $data['name'];
}
if (isset($data['type'])) {
$updates[] = 'type = ?';
$params[] = $data['type'];
}
if (isset($data['is_active'])) {
$updates[] = 'is_active = ?';
$params[] = $data['is_active'];
}
if (empty($updates)) {
return $this->getLocation($code);
}
$params[] = $code;
$sql = 'UPDATE locations SET ' . implode(', ', $updates) . ' WHERE code = ?';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $this->getLocation($code);
}
/**
* Soft delete location (set is_active=0)
*
* @param string $code
* @return bool
* @throws PDOException
*/
public function deleteLocation(string $code): bool
{
$stmt = $this->db->prepare(
'UPDATE locations SET is_active = 0 WHERE code = ?'
);
$stmt->execute([$code]);
return $stmt->rowCount() > 0;
}
/**
* Get gate by location_code and gate_code
*
* @param string $locationCode
* @param string $gateCode
* @return array|null
* @throws PDOException
*/
public function getGate(string $locationCode, string $gateCode): ?array
{
$stmt = $this->db->prepare(
'SELECT location_code, gate_code, name, direction, is_active
FROM gates
WHERE location_code = ? AND gate_code = ?
LIMIT 1'
);
$stmt->execute([$locationCode, $gateCode]);
$result = $stmt->fetch();
return $result !== false ? $result : null;
}
/**
* Create gate
*
* @param array $data
* @return array
* @throws PDOException
*/
public function createGate(array $data): array
{
$direction = isset($data['direction']) ? strtolower($data['direction']) : $data['direction'];
$stmt = $this->db->prepare(
'INSERT INTO gates (location_code, gate_code, name, direction, is_active)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([
$data['location_code'],
$data['gate_code'],
$data['name'],
$direction,
$data['is_active']
]);
return $this->getGate($data['location_code'], $data['gate_code']);
}
/**
* Update gate
*
* @param string $locationCode
* @param string $gateCode
* @param array $data
* @return array|null
* @throws PDOException
*/
public function updateGate(string $locationCode, string $gateCode, array $data): ?array
{
$updates = [];
$params = [];
if (isset($data['name'])) {
$updates[] = 'name = ?';
$params[] = $data['name'];
}
if (isset($data['direction'])) {
$updates[] = 'direction = ?';
$params[] = strtolower($data['direction']);
}
if (isset($data['is_active'])) {
$updates[] = 'is_active = ?';
$params[] = $data['is_active'];
}
if (empty($updates)) {
return $this->getGate($locationCode, $gateCode);
}
$params[] = $locationCode;
$params[] = $gateCode;
$sql = 'UPDATE gates SET ' . implode(', ', $updates) . ' WHERE location_code = ? AND gate_code = ?';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $this->getGate($locationCode, $gateCode);
}
/**
* Soft delete gate (set is_active=0)
*
* @param string $locationCode
* @param string $gateCode
* @return bool
* @throws PDOException
*/
public function deleteGate(string $locationCode, string $gateCode): bool
{
$stmt = $this->db->prepare(
'UPDATE gates SET is_active = 0
WHERE location_code = ? AND gate_code = ?'
);
$stmt->execute([$locationCode, $gateCode]);
return $stmt->rowCount() > 0;
}
/**
* Get tariff by location_code, gate_code, and category
*
* @param string $locationCode
* @param string $gateCode
* @param string $category
* @return array|null
* @throws PDOException
*/
public function getTariff(string $locationCode, string $gateCode, string $category): ?array
{
$stmt = $this->db->prepare(
'SELECT location_code, gate_code, category, amount
FROM tariffs
WHERE location_code = ? AND gate_code = ? AND category = ?
LIMIT 1'
);
$stmt->execute([$locationCode, $gateCode, $category]);
$result = $stmt->fetch();
return $result !== false ? $result : null;
}
/**
* Create tariff
*
* @param array $data
* @return array
* @throws PDOException
*/
public function createTariff(array $data): array
{
$stmt = $this->db->prepare(
'INSERT INTO tariffs (location_code, gate_code, category, amount)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([
$data['location_code'],
$data['gate_code'],
$data['category'],
(int) $data['amount']
]);
return $this->getTariff($data['location_code'], $data['gate_code'], $data['category']);
}
/**
* Update tariff
*
* @param string $locationCode
* @param string $gateCode
* @param string $category
* @param array $data
* @return array|null
* @throws PDOException
*/
public function updateTariff(string $locationCode, string $gateCode, string $category, array $data): ?array
{
$updates = [];
$params = [];
if (isset($data['amount'])) {
$updates[] = 'amount = ?';
$params[] = (int) $data['amount'];
}
if (empty($updates)) {
return $this->getTariff($locationCode, $gateCode, $category);
}
$params[] = $locationCode;
$params[] = $gateCode;
$params[] = $category;
$sql = 'UPDATE tariffs SET ' . implode(', ', $updates) . ' WHERE location_code = ? AND gate_code = ? AND category = ?';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $this->getTariff($locationCode, $gateCode, $category);
}
/**
* Soft delete tariff (delete from table, no is_active field)
*
* @param string $locationCode
* @param string $gateCode
* @param string $category
* @return bool
* @throws PDOException
*/
public function deleteTariff(string $locationCode, string $gateCode, string $category): bool
{
$stmt = $this->db->prepare(
'DELETE FROM tariffs
WHERE location_code = ? AND gate_code = ? AND category = ?'
);
$stmt->execute([$locationCode, $gateCode, $category]);
return $stmt->rowCount() > 0;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Frontend;
use App\Support\ResponseHelper;
use App\Support\Validator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class StreamController
{
private RetribusiReadService $service;
public function __construct(RetribusiReadService $service)
{
$this->service = $service;
}
public function getStreams(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$queryParams = $request->getQueryParams();
[$page, $limit] = Validator::validatePagination($queryParams);
$data = $this->service->getStreams($page, $limit);
$total = $this->service->getStreamsTotal();
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'meta' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'pages' => (int) ceil($total / $limit)
],
'timestamp' => time()
]
);
}
}

View File

@@ -0,0 +1,346 @@
<?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 TariffController
{
private RetribusiWriteService $writeService;
private AuditService $auditService;
public function __construct(
RetribusiWriteService $writeService,
AuditService $auditService
) {
$this->writeService = $writeService;
$this->auditService = $auditService;
}
public function createTariff(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$body = $request->getParsedBody();
if (!is_array($body)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['body' => 'Invalid JSON body']
],
422
);
}
$errors = Validator::validateTariff($body, false);
if (!empty($errors)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => $errors
],
422
);
}
try {
$existing = $this->writeService->getTariff(
$body['location_code'],
$body['gate_code'],
$body['category']
);
if ($existing !== null) {
return ResponseHelper::json(
$response,
[
'error' => 'conflict',
'message' => 'Tariff with this location_code, gate_code, and category already exists'
],
409
);
}
$data = $this->writeService->createTariff($body);
$entityKey = $body['location_code'] . ':' . $body['gate_code'] . ':' . $body['category'];
$serverParams = $request->getServerParams();
$this->auditService->log(
(int) $request->getAttribute('user_id'),
$request->getAttribute('username', ''),
$request->getAttribute('role', ''),
'create',
'tariffs',
$entityKey,
null,
$data,
AuditService::getClientIp($serverParams),
$serverParams['HTTP_USER_AGENT'] ?? null
);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'timestamp' => time()
],
201
);
} catch (PDOException $e) {
if ($e->getCode() === '23000') {
return ResponseHelper::json(
$response,
[
'error' => 'conflict',
'message' => 'Tariff with this location_code, gate_code, and category already exists'
],
409
);
}
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
public function updateTariff(
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
);
}
$body = $request->getParsedBody();
if (!is_array($body)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['body' => 'Invalid JSON body']
],
422
);
}
// Prevent changing immutable fields
if (isset($body['location_code']) && $body['location_code'] !== $locationCode) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['location_code' => 'Location code is immutable']
],
422
);
}
if (isset($body['gate_code']) && $body['gate_code'] !== $gateCode) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['gate_code' => 'Gate code is immutable']
],
422
);
}
if (isset($body['category']) && $body['category'] !== $category) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['category' => 'Category is immutable']
],
422
);
}
$errors = Validator::validateTariff($body, true);
if (!empty($errors)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => $errors
],
422
);
}
try {
$before = $this->writeService->getTariff($locationCode, $gateCode, $category);
if ($before === null) {
return ResponseHelper::json(
$response,
[
'error' => 'not_found',
'message' => 'Tariff not found'
],
404
);
}
$after = $this->writeService->updateTariff($locationCode, $gateCode, $category, $body);
if ($after === null) {
return ResponseHelper::json(
$response,
[
'error' => 'not_found',
'message' => 'Tariff not found'
],
404
);
}
$entityKey = $locationCode . ':' . $gateCode . ':' . $category;
$serverParams = $request->getServerParams();
$this->auditService->log(
(int) $request->getAttribute('user_id'),
$request->getAttribute('username', ''),
$request->getAttribute('role', ''),
'update',
'tariffs',
$entityKey,
$before,
$after,
AuditService::getClientIp($serverParams),
$serverParams['HTTP_USER_AGENT'] ?? null
);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $after,
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
public function deleteTariff(
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
);
}
try {
$before = $this->writeService->getTariff($locationCode, $gateCode, $category);
if ($before === null) {
return ResponseHelper::json(
$response,
[
'error' => 'not_found',
'message' => 'Tariff not found'
],
404
);
}
$deleted = $this->writeService->deleteTariff($locationCode, $gateCode, $category);
if (!$deleted) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Failed to delete tariff'
],
500
);
}
$entityKey = $locationCode . ':' . $gateCode . ':' . $category;
$serverParams = $request->getServerParams();
$this->auditService->log(
(int) $request->getAttribute('user_id'),
$request->getAttribute('username', ''),
$request->getAttribute('role', ''),
'delete',
'tariffs',
$entityKey,
$before,
null,
AuditService::getClientIp($serverParams),
$serverParams['HTTP_USER_AGENT'] ?? null
);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => ['deleted' => true],
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Ingest;
use App\Support\ResponseHelper;
use App\Support\Validator;
use PDOException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class IngestController
{
private IngestService $service;
public function __construct(IngestService $service)
{
$this->service = $service;
}
public function ingest(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$body = $request->getParsedBody();
// Validate request body
if (!is_array($body)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['body' => 'Invalid JSON body']
],
422
);
}
// Basic format validation
$errors = Validator::validateIngest($body);
if (!empty($errors)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => $errors
],
422
);
}
// Get source IP
$serverParams = $request->getServerParams();
$sourceIp = $serverParams['REMOTE_ADDR'] ?? '0.0.0.0';
try {
// Process ingest with database validation
$result = $this->service->processIngest($body, $sourceIp);
if (!$result['valid']) {
// Not found error
if ($result['code'] === 'not_found') {
return ResponseHelper::json(
$response,
[
'error' => 'not_found',
'message' => $result['error']
],
404
);
}
// Other validation errors
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'message' => $result['error']
],
422
);
}
// Success
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => ['stored' => true],
'timestamp' => time()
]
);
} catch (PDOException $e) {
// Database error
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Ingest;
use App\Support\Database;
use PDO;
use PDOException;
use RuntimeException;
class IngestService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Validate location exists and is active
*
* @param string $locationCode
* @return bool
* @throws PDOException
*/
private function validateLocation(string $locationCode): bool
{
$stmt = $this->db->prepare(
'SELECT COUNT(*) FROM locations WHERE code = ? AND is_active = 1'
);
$stmt->execute([$locationCode]);
return (int) $stmt->fetchColumn() > 0;
}
/**
* Validate gate exists, is active, and matches location
*
* @param string $locationCode
* @param string $gateCode
* @return bool
* @throws PDOException
*/
private function validateGate(string $locationCode, string $gateCode): bool
{
$stmt = $this->db->prepare(
'SELECT COUNT(*) FROM gates
WHERE location_code = ? AND gate_code = ? AND is_active = 1'
);
$stmt->execute([$locationCode, $gateCode]);
return (int) $stmt->fetchColumn() > 0;
}
/**
* Validate tariff exists for location+gate+category
*
* @param string $locationCode
* @param string $gateCode
* @param string $category
* @return bool
* @throws PDOException
*/
private function validateTariff(string $locationCode, string $gateCode, string $category): bool
{
$stmt = $this->db->prepare(
'SELECT COUNT(*) FROM tariffs
WHERE location_code = ? AND gate_code = ? AND category = ?'
);
$stmt->execute([$locationCode, $gateCode, $category]);
return (int) $stmt->fetchColumn() > 0;
}
/**
* Process ingest data with validation and storage
*
* @param array $data
* @param string $sourceIp
* @return array ['valid' => bool, 'error' => string|null, 'code' => string]
* @throws PDOException
*/
public function processIngest(array $data, string $sourceIp): array
{
$locationCode = $data['location_code'];
$gateCode = $data['gate_code'];
$category = $data['category'];
// Validate location exists and is active
if (!$this->validateLocation($locationCode)) {
return [
'valid' => false,
'error' => 'Location not found or inactive',
'code' => 'not_found'
];
}
// Validate gate exists, is active, and matches location
if (!$this->validateGate($locationCode, $gateCode)) {
return [
'valid' => false,
'error' => 'Gate not found, inactive, or does not match location',
'code' => 'not_found'
];
}
// Validate tariff exists
if (!$this->validateTariff($locationCode, $gateCode, $category)) {
return [
'valid' => false,
'error' => 'Tariff not found for location+gate+category',
'code' => 'not_found'
];
}
// Insert event
$eventTime = date('Y-m-d H:i:s', $data['timestamp']);
$stmt = $this->db->prepare(
'INSERT INTO entry_events
(location_code, gate_code, category, event_time, source_ip, created_at)
VALUES (?, ?, ?, ?, ?, NOW())'
);
$stmt->execute([
$locationCode,
$gateCode,
$category,
$eventTime,
$sourceIp
]);
// Publish to realtime events (best effort - don't fail ingest if this fails)
$this->publishRealtimeEvent($locationCode, $gateCode, $category, $data['timestamp']);
return [
'valid' => true,
'error' => null,
'code' => null
];
}
/**
* Publish event to realtime_events (best effort)
*
* @param string $locationCode
* @param string $gateCode
* @param string $category
* @param int $eventTimestamp
* @return void
*/
private function publishRealtimeEvent(
string $locationCode,
string $gateCode,
string $category,
int $eventTimestamp
): void {
try {
$stmt = $this->db->prepare(
'INSERT INTO realtime_events
(location_code, gate_code, category, event_time, total_count_delta, created_at)
VALUES (?, ?, ?, ?, 1, NOW())'
);
$stmt->execute([
$locationCode,
$gateCode,
$category,
$eventTimestamp
]);
} catch (PDOException $e) {
// Best effort: log error but don't fail ingest
// In production, you might want to log this to a file or monitoring system
error_log('Failed to publish realtime event: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Realtime;
use PDOException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class RealtimeController
{
private RealtimeService $service;
public function __construct(RealtimeService $service)
{
$this->service = $service;
}
/**
* SSE stream endpoint
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*/
public function stream(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
// Disable output buffering for SSE
if (function_exists('ini_set')) {
ini_set('output_buffering', 'off');
ini_set('zlib.output_compression', 0);
}
// Set SSE headers
$response = $response
->withHeader('Content-Type', 'text/event-stream')
->withHeader('Cache-Control', 'no-cache')
->withHeader('Connection', 'keep-alive')
->withHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
// Get query parameters
$queryParams = $request->getQueryParams();
$lastId = isset($queryParams['last_id']) && is_numeric($queryParams['last_id'])
? (int) $queryParams['last_id']
: 0;
$locationCode = $queryParams['location_code'] ?? null;
if ($locationCode !== null && !is_string($locationCode)) {
$locationCode = null;
}
// Get response body stream
$body = $response->getBody();
// Send initial connection message
$this->writeSSE($body, 'connected', ['time' => time()], 0);
// Bounded loop: max 30 seconds per connection
$maxDuration = 30;
$startTime = time();
$lastPingTime = time();
while ((time() - $startTime) < $maxDuration) {
try {
// Get new events
$events = $this->service->getNewEvents($lastId, $locationCode, 100);
if (!empty($events)) {
foreach ($events as $event) {
$eventData = [
'location_code' => $event['location_code'],
'gate_code' => $event['gate_code'],
'category' => $event['category'],
'event_time' => (int) $event['event_time'],
'delta' => (int) $event['total_count_delta']
];
$this->writeSSE($body, 'ingest', $eventData, (int) $event['id']);
$lastId = (int) $event['id'];
$lastPingTime = time();
}
} else {
// No new events, send ping every 10 seconds
if ((time() - $lastPingTime) >= 10) {
$this->writeSSE($body, 'ping', ['time' => time()], $lastId);
$lastPingTime = time();
}
}
// Small sleep to prevent CPU spinning
usleep(500000); // 0.5 seconds
} catch (PDOException $e) {
// Send error event and break
$this->writeSSE($body, 'error', ['message' => 'Database error'], $lastId);
break;
}
}
// Send close message
$this->writeSSE($body, 'close', ['message' => 'Connection timeout'], $lastId);
return $response;
}
/**
* Write SSE event to stream
*
* @param \Psr\Http\Message\StreamInterface $body
* @param string $eventType
* @param array $data
* @param int $eventId
* @return void
*/
private function writeSSE(
\Psr\Http\Message\StreamInterface $body,
string $eventType,
array $data,
int $eventId
): void {
$message = "id: {$eventId}\n";
$message .= "event: {$eventType}\n";
$message .= "data: " . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
$body->write($message);
$body->flush();
// Also flush PHP output buffer
if (ob_get_level() > 0) {
ob_flush();
}
flush();
// FastCGI finish request if available
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
}
/**
* Get snapshot data
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*/
public function getSnapshot(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$queryParams = $request->getQueryParams();
$date = $queryParams['date'] ?? date('Y-m-d');
// Validate date format
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
return \App\Support\ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
],
422
);
}
$locationCode = $queryParams['location_code'] ?? null;
if ($locationCode !== null && !is_string($locationCode)) {
$locationCode = null;
}
try {
$data = $this->service->getSnapshot($date, $locationCode);
return \App\Support\ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'timestamp' => time()
]
);
} catch (PDOException $e) {
return \App\Support\ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Realtime;
use App\Config\AppConfig;
use App\Middleware\JwtMiddleware;
use App\Support\Database;
use Slim\App;
class RealtimeRoutes
{
/**
* Register realtime routes
*
* @param App $app
* @return void
*/
public static function register(App $app): void
{
// JWT middleware (all roles can access)
$jwtMiddleware = new JwtMiddleware();
// Get database connection
$dbHost = AppConfig::get('DB_HOST', 'localhost');
$dbName = AppConfig::get('DB_NAME', '');
$dbUser = AppConfig::get('DB_USER', '');
$dbPass = AppConfig::get('DB_PASS', '');
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
// Initialize service and controller
$realtimeService = new RealtimeService($db);
$realtimeController = new RealtimeController($realtimeService);
// Register routes
$app->group('/retribusi', function ($group) use (
$jwtMiddleware,
$realtimeController
) {
$group->group('/v1', function ($v1Group) use (
$jwtMiddleware,
$realtimeController
) {
$v1Group->group('/realtime', function ($realtimeGroup) use ($realtimeController) {
$realtimeGroup->get('/stream', [$realtimeController, 'stream']);
$realtimeGroup->get('/snapshot', [$realtimeController, 'getSnapshot']);
})->add($jwtMiddleware);
});
});
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Realtime;
use PDO;
use PDOException;
class RealtimeService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Get new events since last_id
*
* @param int $lastId
* @param string|null $locationCode
* @param int $limit
* @return array
* @throws PDOException
*/
public function getNewEvents(int $lastId = 0, ?string $locationCode = null, int $limit = 100): array
{
$sql = "
SELECT
id,
location_code,
gate_code,
category,
event_time,
total_count_delta,
created_at
FROM realtime_events
WHERE id > ?
";
$params = [$lastId];
if ($locationCode !== null) {
$sql .= " AND location_code = ?";
$params[] = $locationCode;
}
$sql .= " ORDER BY id ASC LIMIT ?";
$params[] = $limit;
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Get snapshot data for today
*
* @param string $date
* @param string|null $locationCode
* @return array
* @throws PDOException
*/
public function getSnapshot(string $date, ?string $locationCode = null): array
{
// Get total count and amount from daily_summary (fast)
$sql = "
SELECT
SUM(total_count) as total_count_today,
SUM(total_amount) as total_amount_today
FROM daily_summary
WHERE summary_date = ?
";
$params = [$date];
if ($locationCode !== null) {
$sql .= " AND location_code = ?";
$params[] = $locationCode;
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$summary = $stmt->fetch();
$totalCountToday = (int) ($summary['total_count_today'] ?? 0);
$totalAmountToday = (int) ($summary['total_amount_today'] ?? 0);
// Get by gate from daily_summary
$gatesSql = "
SELECT
gate_code,
SUM(total_count) as total_count,
SUM(total_amount) as total_amount
FROM daily_summary
WHERE summary_date = ?
";
$gatesParams = [$date];
if ($locationCode !== null) {
$gatesSql .= " AND location_code = ?";
$gatesParams[] = $locationCode;
}
$gatesSql .= " GROUP BY gate_code ORDER BY gate_code ASC";
$gatesStmt = $this->db->prepare($gatesSql);
$gatesStmt->execute($gatesParams);
$byGate = $gatesStmt->fetchAll();
// Format by_gate
$byGateFormatted = [];
foreach ($byGate as $row) {
$byGateFormatted[] = [
'gate_code' => $row['gate_code'],
'total_count' => (int) $row['total_count'],
'total_amount' => (int) $row['total_amount']
];
}
// Get by category from daily_summary
$categorySql = "
SELECT
category,
SUM(total_count) as total_count,
SUM(total_amount) as total_amount
FROM daily_summary
WHERE summary_date = ?
";
$categoryParams = [$date];
if ($locationCode !== null) {
$categorySql .= " AND location_code = ?";
$categoryParams[] = $locationCode;
}
$categorySql .= " GROUP BY category ORDER BY category ASC";
$categoryStmt = $this->db->prepare($categorySql);
$categoryStmt->execute($categoryParams);
$byCategory = $categoryStmt->fetchAll();
// Format by_category
$byCategoryFormatted = [];
foreach ($byCategory as $row) {
$byCategoryFormatted[] = [
'category' => $row['category'],
'total_count' => (int) $row['total_count'],
'total_amount' => (int) $row['total_amount']
];
}
return [
'total_count_today' => $totalCountToday,
'total_amount_today' => $totalAmountToday,
'by_gate' => $byGateFormatted,
'by_category' => $byCategoryFormatted
];
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi;
use App\Config\AppConfig;
use App\Middleware\ApiKeyMiddleware;
use App\Middleware\JwtMiddleware;
use App\Middleware\RoleMiddleware;
use App\Modules\Retribusi\Frontend\AuditService;
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\Ingest\IngestController;
use App\Modules\Retribusi\Ingest\IngestService;
use App\Support\Database;
use PDO;
use PDOException;
use Slim\App;
class RetribusiRoutes
{
/**
* Register retribusi routes
*
* @param App $app
* @return void
*/
public static function register(App $app): void
{
// Get API key from environment
$apiKey = AppConfig::get('RETRIBUSI_API_KEY', '');
$apiKeyMiddleware = new ApiKeyMiddleware($apiKey);
// JWT middleware for frontend routes
$jwtMiddleware = new JwtMiddleware();
// Role middleware
$operatorRoleMiddleware = new RoleMiddleware(['admin', 'operator']);
$adminRoleMiddleware = new RoleMiddleware(['admin']);
// Get database connection
$dbHost = AppConfig::get('DB_HOST', 'localhost');
$dbName = AppConfig::get('DB_NAME', '');
$dbUser = AppConfig::get('DB_USER', '');
$dbPass = AppConfig::get('DB_PASS', '');
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
// Initialize services with database
$ingestService = new IngestService($db);
$ingestController = new IngestController($ingestService);
$readService = new RetribusiReadService($db);
$writeService = new RetribusiWriteService($db);
$auditService = new AuditService($db);
$gateController = new GateController($readService, $writeService, $auditService);
$locationController = new LocationController($readService, $writeService, $auditService);
$streamController = new StreamController($readService);
$tariffController = new TariffController($writeService, $auditService);
// Register routes
$app->group('/retribusi', function ($group) use (
$apiKeyMiddleware,
$jwtMiddleware,
$operatorRoleMiddleware,
$adminRoleMiddleware,
$ingestController,
$gateController,
$locationController,
$streamController,
$tariffController
) {
$group->group('/v1', function ($v1Group) use (
$apiKeyMiddleware,
$jwtMiddleware,
$operatorRoleMiddleware,
$adminRoleMiddleware,
$ingestController,
$gateController,
$locationController,
$streamController,
$tariffController
) {
// Ingest routes (with API key middleware)
$v1Group->post('/ingest', [$ingestController, 'ingest'])
->add($apiKeyMiddleware);
// Frontend routes (with JWT middleware)
$v1Group->group('/frontend', function ($frontendGroup) use (
$operatorRoleMiddleware,
$adminRoleMiddleware,
$gateController,
$locationController,
$streamController,
$tariffController
) {
// Read routes (viewer, operator, admin)
$frontendGroup->get('/gates', [$gateController, 'getGates']);
$frontendGroup->get('/locations', [$locationController, 'getLocations']);
$frontendGroup->get('/streams', [$streamController, 'getStreams']);
// Write routes (operator, admin)
$frontendGroup->post('/locations', [$locationController, 'createLocation'])
->add($operatorRoleMiddleware);
$frontendGroup->put('/locations/{code}', [$locationController, 'updateLocation'])
->add($operatorRoleMiddleware);
$frontendGroup->delete('/locations/{code}', [$locationController, 'deleteLocation'])
->add($adminRoleMiddleware);
$frontendGroup->post('/gates', [$gateController, 'createGate'])
->add($operatorRoleMiddleware);
$frontendGroup->put('/gates/{location_code}/{gate_code}', [$gateController, 'updateGate'])
->add($operatorRoleMiddleware);
$frontendGroup->delete('/gates/{location_code}/{gate_code}', [$gateController, 'deleteGate'])
->add($adminRoleMiddleware);
$frontendGroup->post('/tariffs', [$tariffController, 'createTariff'])
->add($operatorRoleMiddleware);
$frontendGroup->put('/tariffs/{location_code}/{gate_code}/{category}', [$tariffController, 'updateTariff'])
->add($operatorRoleMiddleware);
$frontendGroup->delete('/tariffs/{location_code}/{gate_code}/{category}', [$tariffController, 'deleteTariff'])
->add($adminRoleMiddleware);
})->add($jwtMiddleware);
});
});
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Summary;
use PDO;
use PDOException;
class DailySummaryService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Aggregate daily summary for a specific date
*
* @param string $date Format: Y-m-d
* @return array ['rows_processed' => int, 'date' => string]
* @throws PDOException
*/
public function aggregateForDate(string $date): array
{
// Validate date format
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
throw new \InvalidArgumentException('Invalid date format. Expected Y-m-d');
}
$this->db->beginTransaction();
try {
// Aggregate from entry_events
// Only count events from active locations, gates, and tariffs
$sql = "
SELECT
DATE(e.event_time) as summary_date,
e.location_code,
e.gate_code,
e.category,
COUNT(*) as total_count,
COALESCE(t.amount, 0) as tariff_amount
FROM entry_events e
INNER JOIN locations l ON e.location_code = l.code AND l.is_active = 1
INNER JOIN gates g ON e.location_code = g.location_code
AND e.gate_code = g.gate_code
AND g.is_active = 1
LEFT JOIN tariffs t ON e.location_code = t.location_code
AND e.gate_code = t.gate_code
AND e.category = t.category
WHERE DATE(e.event_time) = ?
GROUP BY
DATE(e.event_time),
e.location_code,
e.gate_code,
e.category,
t.amount
";
$stmt = $this->db->prepare($sql);
$stmt->execute([$date]);
$aggregated = $stmt->fetchAll();
$rowsProcessed = 0;
// Upsert to daily_summary
$upsertSql = "
INSERT INTO daily_summary
(summary_date, location_code, gate_code, category, total_count, total_amount, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
total_count = VALUES(total_count),
total_amount = VALUES(total_amount),
updated_at = NOW()
";
$upsertStmt = $this->db->prepare($upsertSql);
foreach ($aggregated as $row) {
$totalAmount = (int) $row['total_count'] * (int) $row['tariff_amount'];
$upsertStmt->execute([
$row['summary_date'],
$row['location_code'],
$row['gate_code'],
$row['category'],
(int) $row['total_count'],
$totalAmount
]);
$rowsProcessed++;
}
$this->db->commit();
return [
'rows_processed' => $rowsProcessed,
'date' => $date
];
} catch (PDOException $e) {
$this->db->rollBack();
throw $e;
}
}
/**
* Get daily summary data
*
* @param string $date
* @param string|null $locationCode
* @param string|null $gateCode
* @return array
* @throws PDOException
*/
public function getDailySummary(
string $date,
?string $locationCode = null,
?string $gateCode = null
): array {
$sql = "
SELECT
summary_date,
location_code,
gate_code,
category,
total_count,
total_amount
FROM daily_summary
WHERE summary_date = ?
";
$params = [$date];
if ($locationCode !== null) {
$sql .= " AND location_code = ?";
$params[] = $locationCode;
}
if ($gateCode !== null) {
$sql .= " AND gate_code = ?";
$params[] = $gateCode;
}
$sql .= " ORDER BY location_code, gate_code, category";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Summary;
use PDO;
use PDOException;
class HourlySummaryService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Aggregate hourly summary for a specific date
*
* @param string $date Format: Y-m-d
* @return array ['rows_processed' => int, 'date' => string]
* @throws PDOException
*/
public function aggregateForDate(string $date): array
{
// Validate date format
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
throw new \InvalidArgumentException('Invalid date format. Expected Y-m-d');
}
$this->db->beginTransaction();
try {
// Aggregate from entry_events
// Group by hour, location, gate, category
// Only count events from active locations, gates, and tariffs
$sql = "
SELECT
DATE(e.event_time) as summary_date,
HOUR(e.event_time) as summary_hour,
e.location_code,
e.gate_code,
e.category,
COUNT(*) as total_count,
COALESCE(t.amount, 0) as tariff_amount
FROM entry_events e
INNER JOIN locations l ON e.location_code = l.code AND l.is_active = 1
INNER JOIN gates g ON e.location_code = g.location_code
AND e.gate_code = g.gate_code
AND g.is_active = 1
LEFT JOIN tariffs t ON e.location_code = t.location_code
AND e.gate_code = t.gate_code
AND e.category = t.category
AND t.is_active = 1
WHERE DATE(e.event_time) = ?
GROUP BY
DATE(e.event_time),
HOUR(e.event_time),
e.location_code,
e.gate_code,
e.category,
t.amount
";
$stmt = $this->db->prepare($sql);
$stmt->execute([$date]);
$aggregated = $stmt->fetchAll();
$rowsProcessed = 0;
// Upsert to hourly_summary
$upsertSql = "
INSERT INTO hourly_summary
(summary_date, summary_hour, location_code, gate_code, category, total_count, total_amount, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
total_count = VALUES(total_count),
total_amount = VALUES(total_amount),
updated_at = NOW()
";
$upsertStmt = $this->db->prepare($upsertSql);
foreach ($aggregated as $row) {
$totalAmount = (int) $row['total_count'] * (int) $row['tariff_amount'];
$upsertStmt->execute([
$row['summary_date'],
(int) $row['summary_hour'],
$row['location_code'],
$row['gate_code'],
$row['category'],
(int) $row['total_count'],
$totalAmount
]);
$rowsProcessed++;
}
$this->db->commit();
return [
'rows_processed' => $rowsProcessed,
'date' => $date
];
} catch (PDOException $e) {
$this->db->rollBack();
throw $e;
}
}
/**
* Get hourly summary data for chart
*
* @param string $date
* @param string|null $locationCode
* @param string|null $gateCode
* @return array
* @throws PDOException
*/
public function getHourlySummary(
string $date,
?string $locationCode = null,
?string $gateCode = null
): array {
$sql = "
SELECT
summary_hour,
SUM(total_count) as total_count,
SUM(total_amount) as total_amount
FROM hourly_summary
WHERE summary_date = ?
";
$params = [$date];
if ($locationCode !== null) {
$sql .= " AND location_code = ?";
$params[] = $locationCode;
}
if ($gateCode !== null) {
$sql .= " AND gate_code = ?";
$params[] = $gateCode;
}
$sql .= " GROUP BY summary_hour ORDER BY summary_hour ASC";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$results = $stmt->fetchAll();
// Initialize arrays for all 24 hours (0-23)
$hourlyData = [];
for ($hour = 0; $hour < 24; $hour++) {
$hourlyData[$hour] = [
'total_count' => 0,
'total_amount' => 0
];
}
// Fill in actual data
foreach ($results as $row) {
$hour = (int) $row['summary_hour'];
$hourlyData[$hour] = [
'total_count' => (int) $row['total_count'],
'total_amount' => (int) $row['total_amount']
];
}
// Build labels and series
$labels = [];
$totalCounts = [];
$totalAmounts = [];
for ($hour = 0; $hour < 24; $hour++) {
$labels[] = str_pad((string) $hour, 2, '0', STR_PAD_LEFT);
$totalCounts[] = $hourlyData[$hour]['total_count'];
$totalAmounts[] = $hourlyData[$hour]['total_amount'];
}
return [
'labels' => $labels,
'series' => [
'total_count' => $totalCounts,
'total_amount' => $totalAmounts
]
];
}
}

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Summary;
use App\Support\ResponseHelper;
use InvalidArgumentException;
use PDOException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class SummaryController
{
private DailySummaryService $dailyService;
private HourlySummaryService $hourlyService;
public function __construct(
DailySummaryService $dailyService,
HourlySummaryService $hourlyService
) {
$this->dailyService = $dailyService;
$this->hourlyService = $hourlyService;
}
/**
* Trigger daily summary aggregation (admin only)
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*/
public function aggregateDaily(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$body = $request->getParsedBody();
if (!is_array($body)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['body' => 'Invalid JSON body']
],
422
);
}
$date = $body['date'] ?? null;
if ($date === null || !is_string($date)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Field is required and must be a string (Y-m-d format)']
],
422
);
}
// Validate date format
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
],
422
);
}
try {
$result = $this->dailyService->aggregateForDate($date);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $result,
'timestamp' => time()
]
);
} catch (InvalidArgumentException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'message' => $e->getMessage()
],
422
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
/**
* Get daily summary data (viewer/operator/admin)
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*/
public function getDailySummary(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$queryParams = $request->getQueryParams();
$date = $queryParams['date'] ?? null;
if ($date === null || !is_string($date)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Query parameter date is required (Y-m-d format)']
],
422
);
}
// Validate date format
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
],
422
);
}
$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;
}
try {
$data = $this->dailyService->getDailySummary($date, $locationCode, $gateCode);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
/**
* Get hourly summary data for chart (viewer/operator/admin)
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*/
public function getHourlySummary(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$queryParams = $request->getQueryParams();
$date = $queryParams['date'] ?? null;
if ($date === null || !is_string($date)) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Query parameter date is required (Y-m-d format)']
],
422
);
}
// Validate date format
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
return ResponseHelper::json(
$response,
[
'error' => 'validation_error',
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
],
422
);
}
$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;
}
try {
$data = $this->hourlyService->getHourlySummary($date, $locationCode, $gateCode);
return ResponseHelper::json(
$response,
[
'success' => true,
'data' => $data,
'timestamp' => time()
]
);
} catch (PDOException $e) {
return ResponseHelper::json(
$response,
[
'error' => 'server_error',
'message' => 'Database error occurred'
],
500
);
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Modules\Retribusi\Summary;
use App\Config\AppConfig;
use App\Middleware\JwtMiddleware;
use App\Middleware\RoleMiddleware;
use App\Support\Database;
use Slim\App;
class SummaryRoutes
{
/**
* Register summary routes
*
* @param App $app
* @return void
*/
public static function register(App $app): void
{
// JWT middleware
$jwtMiddleware = new JwtMiddleware();
// Admin role middleware
$adminRoleMiddleware = new RoleMiddleware(['admin']);
// Get database connection
$dbHost = AppConfig::get('DB_HOST', 'localhost');
$dbName = AppConfig::get('DB_NAME', '');
$dbUser = AppConfig::get('DB_USER', '');
$dbPass = AppConfig::get('DB_PASS', '');
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
// Initialize services and controller
$dailySummaryService = new DailySummaryService($db);
$hourlySummaryService = new HourlySummaryService($db);
$summaryController = new SummaryController($dailySummaryService, $hourlySummaryService);
// Register routes
$app->group('/retribusi', function ($group) use (
$jwtMiddleware,
$adminRoleMiddleware,
$summaryController
) {
$group->group('/v1', function ($v1Group) use (
$jwtMiddleware,
$adminRoleMiddleware,
$summaryController
) {
// Admin endpoint: trigger aggregation
$v1Group->post('/admin/summary/daily', [$summaryController, 'aggregateDaily'])
->add($adminRoleMiddleware)
->add($jwtMiddleware);
// Read endpoints: get summaries
$v1Group->get('/summary/daily', [$summaryController, 'getDailySummary'])
->add($jwtMiddleware);
$v1Group->get('/summary/hourly', [$summaryController, 'getHourlySummary'])
->add($jwtMiddleware);
});
});
}
}

60
src/Support/Database.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Support;
use PDO;
use PDOException;
class Database
{
private static ?PDO $connection = null;
/**
* Get database connection (singleton)
*
* @param string $host
* @param string $dbName
* @param string $user
* @param string $password
* @return PDO
* @throws PDOException
*/
public static function getConnection(
string $host,
string $dbName,
string $user,
string $password
): PDO {
if (self::$connection === null) {
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=utf8mb4',
$host,
$dbName
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_STRINGIFY_FETCHES => false,
];
self::$connection = new PDO($dsn, $user, $password, $options);
}
return self::$connection;
}
/**
* Reset connection (for testing)
*
* @return void
*/
public static function reset(): void
{
self::$connection = null;
}
}

117
src/Support/Jwt.php Normal file
View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Support;
use InvalidArgumentException;
use RuntimeException;
class Jwt
{
/**
* Encode JWT token
*
* @param array $payload
* @param string $secret
* @param int $ttlSeconds
* @param string $issuer
* @return string
*/
public static function encode(
array $payload,
string $secret,
int $ttlSeconds,
string $issuer
): string {
$header = [
'alg' => 'HS256',
'typ' => 'JWT'
];
$now = time();
$claims = [
'iss' => $issuer,
'iat' => $now,
'exp' => $now + $ttlSeconds,
...$payload
];
$headerEncoded = self::base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES));
$payloadEncoded = self::base64UrlEncode(json_encode($claims, JSON_UNESCAPED_SLASHES));
$signature = hash_hmac('sha256', $headerEncoded . '.' . $payloadEncoded, $secret, true);
$signatureEncoded = self::base64UrlEncode($signature);
return $headerEncoded . '.' . $payloadEncoded . '.' . $signatureEncoded;
}
/**
* Decode and validate JWT token
*
* @param string $token
* @param string $secret
* @return array
* @throws InvalidArgumentException|RuntimeException
*/
public static function decode(string $token, string $secret): array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
throw new InvalidArgumentException('Invalid token format');
}
[$headerEncoded, $payloadEncoded, $signatureEncoded] = $parts;
// Verify signature
$signature = self::base64UrlDecode($signatureEncoded);
$expectedSignature = hash_hmac(
'sha256',
$headerEncoded . '.' . $payloadEncoded,
$secret,
true
);
if (!hash_equals($expectedSignature, $signature)) {
throw new RuntimeException('Invalid token signature');
}
// Decode payload
$payload = json_decode(self::base64UrlDecode($payloadEncoded), true);
if ($payload === null) {
throw new InvalidArgumentException('Invalid token payload');
}
// Check expiration
if (isset($payload['exp']) && $payload['exp'] < time()) {
throw new RuntimeException('Token expired');
}
return $payload;
}
/**
* Base64 URL encode
*
* @param string $data
* @return string
*/
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Base64 URL decode
*
* @param string $data
* @return string
*/
private static function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
}
}

32
src/Support/Response.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Response;
class ResponseHelper
{
/**
* Create JSON response
*
* @param ResponseInterface $response
* @param mixed $data
* @param int $statusCode
* @return ResponseInterface
*/
public static function json(
ResponseInterface $response,
mixed $data,
int $statusCode = 200
): ResponseInterface {
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($statusCode);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Psr\Http\Message\ResponseInterface;
class ResponseHelper
{
/**
* Create JSON response
*
* @param ResponseInterface $response
* @param mixed $data
* @param int $statusCode
* @return ResponseInterface
*/
public static function json(
ResponseInterface $response,
mixed $data,
int $statusCode = 200
): ResponseInterface {
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($statusCode);
}
}

298
src/Support/Validator.php Normal file
View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\Support;
class Validator
{
/**
* Validate ingest payload (basic format validation)
*
* @param array $data
* @return array Errors array, empty if valid
*/
public static function validateIngest(array $data): array
{
$errors = [];
// Validate timestamp: required integer
if (!isset($data['timestamp'])) {
$errors['timestamp'] = 'Field is required';
} elseif (!is_int($data['timestamp'])) {
$errors['timestamp'] = 'Must be an integer';
}
// Validate location_code: required string, min 1, max 64
if (!isset($data['location_code'])) {
$errors['location_code'] = 'Field is required';
} elseif (!is_string($data['location_code'])) {
$errors['location_code'] = 'Must be a string';
} elseif (strlen($data['location_code']) < 1) {
$errors['location_code'] = 'Must be at least 1 character';
} elseif (strlen($data['location_code']) > 64) {
$errors['location_code'] = 'Must not exceed 64 characters';
}
// Validate gate_code: required string, min 1, max 64
if (!isset($data['gate_code'])) {
$errors['gate_code'] = 'Field is required';
} elseif (!is_string($data['gate_code'])) {
$errors['gate_code'] = 'Must be a string';
} elseif (strlen($data['gate_code']) < 1) {
$errors['gate_code'] = 'Must be at least 1 character';
} elseif (strlen($data['gate_code']) > 64) {
$errors['gate_code'] = 'Must not exceed 64 characters';
}
// Validate category: required string, min 1, max 64
if (!isset($data['category'])) {
$errors['category'] = 'Field is required';
} elseif (!is_string($data['category'])) {
$errors['category'] = 'Must be a string';
} elseif (strlen($data['category']) < 1) {
$errors['category'] = 'Must be at least 1 character';
} elseif (strlen($data['category']) > 64) {
$errors['category'] = 'Must not exceed 64 characters';
}
return $errors;
}
/**
* Validate pagination parameters
*
* @param array $queryParams
* @return array [page, limit]
*/
public static function validatePagination(array $queryParams): array
{
$page = isset($queryParams['page']) && is_numeric($queryParams['page'])
? max(1, (int) $queryParams['page'])
: 1;
$limit = isset($queryParams['limit']) && is_numeric($queryParams['limit'])
? max(1, min(100, (int) $queryParams['limit']))
: 20;
return [$page, $limit];
}
/**
* Validate code format (location_code, gate_code, category)
*
* @param string $code
* @param string $fieldName
* @return string|null Error message or null if valid
*/
private static function validateCodeFormat(string $code, string $fieldName): ?string
{
if (!preg_match('/^[a-z0-9_\\-]{1,64}$/', $code)) {
return $fieldName . ' must match pattern: ^[a-z0-9_\\-]{1,64}$';
}
return null;
}
/**
* Validate location data
*
* @param array $data
* @param bool $isUpdate
* @return array Errors array, empty if valid
*/
public static function validateLocation(array $data, bool $isUpdate = false): array
{
$errors = [];
// Code: required for POST, immutable for PUT
if (!$isUpdate) {
if (!isset($data['code'])) {
$errors['code'] = 'Field is required';
} elseif (!is_string($data['code'])) {
$errors['code'] = 'Must be a string';
} else {
$codeError = self::validateCodeFormat($data['code'], 'code');
if ($codeError !== null) {
$errors['code'] = $codeError;
}
}
}
// Name: optional for update, but if provided must be valid
if (isset($data['name'])) {
if (!is_string($data['name'])) {
$errors['name'] = 'Must be a string';
} elseif (strlen($data['name']) > 120) {
$errors['name'] = 'Must not exceed 120 characters';
}
} elseif (!$isUpdate) {
$errors['name'] = 'Field is required';
}
// Type: optional for update
if (isset($data['type'])) {
if (!is_string($data['type'])) {
$errors['type'] = 'Must be a string';
} elseif (strlen($data['type']) > 60) {
$errors['type'] = 'Must not exceed 60 characters';
}
} elseif (!$isUpdate) {
$errors['type'] = 'Field is required';
}
// 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)) {
$errors['is_active'] = 'Must be 0 or 1';
}
} elseif (!$isUpdate) {
$errors['is_active'] = 'Field is required';
}
return $errors;
}
/**
* Validate gate data
*
* @param array $data
* @param bool $isUpdate
* @return array Errors array, empty if valid
*/
public static function validateGate(array $data, bool $isUpdate = false): array
{
$errors = [];
// Location code: required for POST, immutable for PUT
if (!$isUpdate) {
if (!isset($data['location_code'])) {
$errors['location_code'] = 'Field is required';
} elseif (!is_string($data['location_code'])) {
$errors['location_code'] = 'Must be a string';
} else {
$codeError = self::validateCodeFormat($data['location_code'], 'location_code');
if ($codeError !== null) {
$errors['location_code'] = $codeError;
}
}
}
// Gate code: required for POST, immutable for PUT
if (!$isUpdate) {
if (!isset($data['gate_code'])) {
$errors['gate_code'] = 'Field is required';
} elseif (!is_string($data['gate_code'])) {
$errors['gate_code'] = 'Must be a string';
} else {
$codeError = self::validateCodeFormat($data['gate_code'], 'gate_code');
if ($codeError !== null) {
$errors['gate_code'] = $codeError;
}
}
}
// Name: optional for update
if (isset($data['name'])) {
if (!is_string($data['name'])) {
$errors['name'] = 'Must be a string';
} elseif (strlen($data['name']) > 120) {
$errors['name'] = 'Must not exceed 120 characters';
}
} elseif (!$isUpdate) {
$errors['name'] = 'Field is required';
}
// Direction: optional for update, but if provided must be in/out
if (isset($data['direction'])) {
if (!is_string($data['direction'])) {
$errors['direction'] = 'Must be a string';
} else {
$direction = strtolower($data['direction']);
if (!in_array($direction, ['in', 'out'], true)) {
$errors['direction'] = 'Must be "in" or "out"';
}
}
} elseif (!$isUpdate) {
$errors['direction'] = 'Field is required';
}
// 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)) {
$errors['is_active'] = 'Must be 0 or 1';
}
} elseif (!$isUpdate) {
$errors['is_active'] = 'Field is required';
}
return $errors;
}
/**
* Validate tariff data
*
* @param array $data
* @param bool $isUpdate
* @return array Errors array, empty if valid
*/
public static function validateTariff(array $data, bool $isUpdate = false): array
{
$errors = [];
// Location code: required for POST, immutable for PUT
if (!$isUpdate) {
if (!isset($data['location_code'])) {
$errors['location_code'] = 'Field is required';
} elseif (!is_string($data['location_code'])) {
$errors['location_code'] = 'Must be a string';
} else {
$codeError = self::validateCodeFormat($data['location_code'], 'location_code');
if ($codeError !== null) {
$errors['location_code'] = $codeError;
}
}
}
// Gate code: required for POST, immutable for PUT
if (!$isUpdate) {
if (!isset($data['gate_code'])) {
$errors['gate_code'] = 'Field is required';
} elseif (!is_string($data['gate_code'])) {
$errors['gate_code'] = 'Must be a string';
} else {
$codeError = self::validateCodeFormat($data['gate_code'], 'gate_code');
if ($codeError !== null) {
$errors['gate_code'] = $codeError;
}
}
}
// Category: required for POST, immutable for PUT
if (!$isUpdate) {
if (!isset($data['category'])) {
$errors['category'] = 'Field is required';
} elseif (!is_string($data['category'])) {
$errors['category'] = 'Must be a string';
} else {
$codeError = self::validateCodeFormat($data['category'], 'category');
if ($codeError !== null) {
$errors['category'] = $codeError;
}
}
}
// Amount: required for POST, optional for update
if (isset($data['amount'])) {
if (!is_int($data['amount']) && !is_numeric($data['amount'])) {
$errors['amount'] = 'Must be an integer';
} elseif ((int) $data['amount'] < 0) {
$errors['amount'] = 'Must be >= 0';
}
} elseif (!$isUpdate) {
$errors['amount'] = 'Field is required';
}
return $errors;
}
}