From 39f23388a7404865772127b9c10f6b40b31ad959 Mon Sep 17 00:00:00 2001 From: mwpn Date: Wed, 17 Dec 2025 10:43:03 +0700 Subject: [PATCH] Initial commit: Slim Framework 4 API Retribusi dengan modular architecture --- .env.example | 29 ++ .gitignore | 37 ++ README.md | 207 ++++++++++ bin/daily_summary.php | 72 ++++ bin/hourly_summary.php | 79 ++++ composer.json | 23 ++ migrations/README.md | 57 +++ public/index.php | 32 ++ src/Bootstrap/AppBootstrap.php | 34 ++ src/Bootstrap/app.php | 34 ++ src/Config/AppConfig.php | 41 ++ src/Config/app.php | 28 ++ src/Middleware/ApiKeyMiddleware.php | 52 +++ src/Middleware/JwtMiddleware.php | 85 ++++ src/Middleware/RoleMiddleware.php | 45 +++ src/Modules/Auth/AuthController.php | 102 +++++ src/Modules/Auth/AuthRoutes.php | 49 +++ src/Modules/Auth/AuthService.php | 103 +++++ src/Modules/Health/HealthRoutes.php | 35 ++ src/Modules/Health/Routes.php | 35 ++ .../Dashboard/DashboardController.php | 269 +++++++++++++ .../Retribusi/Dashboard/DashboardRoutes.php | 55 +++ .../Retribusi/Dashboard/DashboardService.php | 220 +++++++++++ .../Retribusi/Frontend/AuditService.php | 92 +++++ .../Retribusi/Frontend/GateController.php | 364 ++++++++++++++++++ .../Retribusi/Frontend/LocationController.php | 349 +++++++++++++++++ .../Frontend/RetribusiReadService.php | 143 +++++++ .../Frontend/RetribusiWriteService.php | 326 ++++++++++++++++ .../Retribusi/Frontend/StreamController.php | 47 +++ .../Retribusi/Frontend/TariffController.php | 346 +++++++++++++++++ .../Retribusi/Ingest/IngestController.php | 108 ++++++ .../Retribusi/Ingest/IngestService.php | 176 +++++++++ .../Retribusi/Realtime/RealtimeController.php | 200 ++++++++++ .../Retribusi/Realtime/RealtimeRoutes.php | 54 +++ .../Retribusi/Realtime/RealtimeService.php | 166 ++++++++ src/Modules/Retribusi/RetribusiRoutes.php | 134 +++++++ .../Retribusi/Summary/DailySummaryService.php | 157 ++++++++ .../Summary/HourlySummaryService.php | 195 ++++++++++ .../Retribusi/Summary/SummaryController.php | 253 ++++++++++++ .../Retribusi/Summary/SummaryRoutes.php | 68 ++++ src/Support/Database.php | 60 +++ src/Support/Jwt.php | 117 ++++++ src/Support/Response.php | 32 ++ src/Support/ResponseHelper.php | 31 ++ src/Support/Validator.php | 298 ++++++++++++++ 45 files changed, 5439 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bin/daily_summary.php create mode 100644 bin/hourly_summary.php create mode 100644 composer.json create mode 100644 migrations/README.md create mode 100644 public/index.php create mode 100644 src/Bootstrap/AppBootstrap.php create mode 100644 src/Bootstrap/app.php create mode 100644 src/Config/AppConfig.php create mode 100644 src/Config/app.php create mode 100644 src/Middleware/ApiKeyMiddleware.php create mode 100644 src/Middleware/JwtMiddleware.php create mode 100644 src/Middleware/RoleMiddleware.php create mode 100644 src/Modules/Auth/AuthController.php create mode 100644 src/Modules/Auth/AuthRoutes.php create mode 100644 src/Modules/Auth/AuthService.php create mode 100644 src/Modules/Health/HealthRoutes.php create mode 100644 src/Modules/Health/Routes.php create mode 100644 src/Modules/Retribusi/Dashboard/DashboardController.php create mode 100644 src/Modules/Retribusi/Dashboard/DashboardRoutes.php create mode 100644 src/Modules/Retribusi/Dashboard/DashboardService.php create mode 100644 src/Modules/Retribusi/Frontend/AuditService.php create mode 100644 src/Modules/Retribusi/Frontend/GateController.php create mode 100644 src/Modules/Retribusi/Frontend/LocationController.php create mode 100644 src/Modules/Retribusi/Frontend/RetribusiReadService.php create mode 100644 src/Modules/Retribusi/Frontend/RetribusiWriteService.php create mode 100644 src/Modules/Retribusi/Frontend/StreamController.php create mode 100644 src/Modules/Retribusi/Frontend/TariffController.php create mode 100644 src/Modules/Retribusi/Ingest/IngestController.php create mode 100644 src/Modules/Retribusi/Ingest/IngestService.php create mode 100644 src/Modules/Retribusi/Realtime/RealtimeController.php create mode 100644 src/Modules/Retribusi/Realtime/RealtimeRoutes.php create mode 100644 src/Modules/Retribusi/Realtime/RealtimeService.php create mode 100644 src/Modules/Retribusi/RetribusiRoutes.php create mode 100644 src/Modules/Retribusi/Summary/DailySummaryService.php create mode 100644 src/Modules/Retribusi/Summary/HourlySummaryService.php create mode 100644 src/Modules/Retribusi/Summary/SummaryController.php create mode 100644 src/Modules/Retribusi/Summary/SummaryRoutes.php create mode 100644 src/Support/Database.php create mode 100644 src/Support/Jwt.php create mode 100644 src/Support/Response.php create mode 100644 src/Support/ResponseHelper.php create mode 100644 src/Support/Validator.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..67042e5 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +๏ปฟAPP_ENV=production +APP_DEBUG=false + +# Server Configuration +SERVER_NAME=localhost +SERVER_PORT=8080 + +# Database Configuration (if needed) +# DB_HOST=localhost +# DB_PORT=3306 +# DB_NAME=your_database +# DB_USER=your_username +# DB_PASS=your_password + +# Security +# JWT_SECRET=your-secret-key-here + +RETRIBUSI_API_KEY=change_me + +# Database Configuration +DB_HOST=localhost +DB_NAME=sql_retribusi +DB_USER=sql_retribusi +DB_PASS=8e5yKwC6WPiLXTst + +# JWT Configuration +JWT_SECRET=change-me-to-secure-random-string +JWT_TTL_SECONDS=3600 +JWT_ISSUER=api-btekno diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af68f56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +/vendor/ +/node_modules/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Cache +cache/ +tmp/ + +# Composer +composer.phar +composer.lock + +# Backup +*.sql +*.bak +backup/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..562c2a8 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# API Retribusi - Slim Framework 4 + +Sistem API Retribusi berbasis Slim Framework 4 dengan arsitektur modular untuk infrastruktur pemerintah. + +## ๐Ÿš€ Fitur + +- **Modular Architecture** - Struktur code yang terorganisir dan mudah di-scale +- **JWT Authentication** - Secure authentication dengan role-based access +- **CRUD Master Data** - Locations, Gates, Tariffs dengan audit logging +- **Realtime Dashboard** - SSE (Server-Sent Events) untuk update real-time +- **Data Aggregation** - Daily & Hourly summary untuk reporting +- **API Key Protection** - X-API-KEY untuk ingest endpoint (mesin YOLO) + +## ๐Ÿ“‹ Requirements + +- PHP >= 8.2 +- MySQL/MariaDB +- Composer +- aaPanel (recommended) atau web server dengan PHP-FPM + +## ๐Ÿ”ง Installation + +1. Clone repository: +```bash +git clone https://git.btekno.cloud/kangmin/api-btekno.git +cd api-btekno +``` + +2. Install dependencies: +```bash +composer install --no-dev --optimize-autoloader +``` + +3. Setup environment: +```bash +cp .env.example .env +# Edit .env dengan konfigurasi database dan JWT +``` + +4. Apply migrations: +```bash +mysql -u your_user -p your_database < migrations/001_create_audit_logs.sql +mysql -u your_user -p your_database < migrations/002_create_hourly_summary.sql +mysql -u your_user -p your_database < migrations/003_create_realtime_events.sql +``` + +5. Setup web server: + - DocumentRoot: `public/` + - PHP 8.2+ + - Enable mod_rewrite (Apache) atau nginx config + +## ๐Ÿ“ Struktur Project + +``` +api-btekno/ +โ”œโ”€โ”€ public/ # Entry point (web server root) +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ Bootstrap/ # App initialization +โ”‚ โ”œโ”€โ”€ Config/ # Configuration +โ”‚ โ”œโ”€โ”€ Middleware/ # Auth & security +โ”‚ โ”œโ”€โ”€ Modules/ # Business modules +โ”‚ โ””โ”€โ”€ Support/ # Utilities +โ”œโ”€โ”€ bin/ # CLI scripts +โ”œโ”€โ”€ migrations/ # Database migrations +โ””โ”€โ”€ vendor/ # Dependencies +``` + +## ๐Ÿ” Environment Variables + +Edit `.env` file: + +```env +# App +APP_ENV=production +APP_DEBUG=false + +# Database +DB_HOST=localhost +DB_NAME=sql_retribusi +DB_USER=sql_retribusi +DB_PASS=your_password + +# JWT +JWT_SECRET=your-secret-key-here +JWT_TTL_SECONDS=3600 +JWT_ISSUER=api-btekno + +# API Key +RETRIBUSI_API_KEY=your-api-key-here +``` + +## ๐Ÿ“ก API Endpoints + +### Authentication +- `POST /auth/v1/login` - Login & get JWT token + +### Ingest (Mesin) +- `POST /retribusi/v1/ingest` - Ingest event data (X-API-KEY required) + +### Frontend CRUD +- `GET /retribusi/v1/frontend/locations` - List locations +- `POST /retribusi/v1/frontend/locations` - Create location (operator+) +- `PUT /retribusi/v1/frontend/locations/{code}` - Update location (operator+) +- `DELETE /retribusi/v1/frontend/locations/{code}` - Delete location (admin) + +Similar endpoints untuk `gates` dan `tariffs`. + +### Summary & Dashboard +- `GET /retribusi/v1/summary/daily` - Daily summary +- `GET /retribusi/v1/summary/hourly` - Hourly summary +- `GET /retribusi/v1/dashboard/daily` - Daily chart data +- `GET /retribusi/v1/dashboard/by-category` - Category chart data +- `GET /retribusi/v1/dashboard/summary` - Summary statistics + +### Realtime +- `GET /retribusi/v1/realtime/stream` - SSE stream (real-time events) +- `GET /retribusi/v1/realtime/snapshot` - Snapshot data + +## ๐Ÿ› ๏ธ CLI Tools + +### Daily Summary +```bash +php bin/daily_summary.php [date] +# Default: yesterday +``` + +### Hourly Summary +```bash +php bin/hourly_summary.php [date] +# Default: yesterday +``` + +### Cron Job Setup +```cron +# Daily summary (run at 1 AM) +0 1 * * * cd /path/to/api-btekno && php bin/daily_summary.php + +# Hourly summary (run at 1 AM) +0 1 * * * cd /path/to/api-btekno && php bin/hourly_summary.php +``` + +## ๐Ÿ”’ Security + +- JWT authentication untuk semua frontend endpoints +- X-API-KEY untuk ingest endpoint +- Role-based access control (viewer/operator/admin) +- Prepared statements (SQL injection prevention) +- Input validation +- Audit logging untuk semua perubahan data + +## ๐Ÿ“Š Database Schema + +- `users` - User authentication +- `locations` - Master lokasi +- `gates` - Master pintu masuk/keluar +- `tariffs` - Master tarif +- `entry_events` - Raw event data +- `daily_summary` - Rekap harian +- `hourly_summary` - Rekap per jam +- `realtime_events` - Ring buffer untuk SSE +- `audit_logs` - Audit trail + +## ๐Ÿงช Testing + +Test endpoint dengan curl atau Postman: + +```bash +# Health check +curl http://localhost/health + +# Login +curl -X POST http://localhost/auth/v1/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"password"}' + +# Get locations (with JWT) +curl http://localhost/retribusi/v1/frontend/locations \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +## ๐Ÿ“ Coding Standards + +- `declare(strict_types=1)` di semua file +- Type hints lengkap +- PSR-4 autoloading +- Controller tipis, logic di service +- No ORM (pure PDO) +- Response JSON konsisten + +## ๐Ÿš€ Deployment + +1. Set production environment di `.env` +2. Run `composer install --no-dev --optimize-autoloader` +3. Apply semua migrations +4. Setup cron jobs untuk summary +5. Configure web server (Apache/Nginx) +6. Enable HTTPS +7. Monitor logs dan performance + +## ๐Ÿ“„ License + +Proprietary + +## ๐Ÿ‘ฅ Author + +BTekno Development Team + diff --git a/bin/daily_summary.php b/bin/daily_summary.php new file mode 100644 index 0000000..4038f20 --- /dev/null +++ b/bin/daily_summary.php @@ -0,0 +1,72 @@ +#!/usr/bin/env php +format('Y-m-d') !== $date) { + echo "Error: Invalid date format. Expected Y-m-d (e.g., 2025-01-01)\n"; + echo "Usage: php bin/daily_summary.php [date]\n"; + exit(1); +} + +try { + // Get database connection + $dbHost = AppConfig::get('DB_HOST', 'localhost'); + $dbName = AppConfig::get('DB_NAME', ''); + $dbUser = AppConfig::get('DB_USER', ''); + $dbPass = AppConfig::get('DB_PASS', ''); + + if (empty($dbName) || empty($dbUser)) { + echo "Error: Database configuration not found in .env\n"; + exit(1); + } + + $db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass); + + // Initialize service + $service = new DailySummaryService($db); + + // Run aggregation + echo "Processing daily summary for date: {$date}\n"; + $result = $service->aggregateForDate($date); + + echo "Success!\n"; + echo "Date: {$result['date']}\n"; + echo "Rows processed: {$result['rows_processed']}\n"; + + exit(0); + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); +} + diff --git a/bin/hourly_summary.php b/bin/hourly_summary.php new file mode 100644 index 0000000..319b802 --- /dev/null +++ b/bin/hourly_summary.php @@ -0,0 +1,79 @@ +#!/usr/bin/env php +format('Y-m-d') !== $date) { + echo "Error: Invalid date format. Expected Y-m-d (e.g., 2025-01-01)\n"; + echo "Usage: php bin/hourly_summary.php [date]\n"; + echo " If date is omitted, defaults to yesterday\n"; + exit(1); +} + +try { + // Get database connection + $dbHost = AppConfig::get('DB_HOST', 'localhost'); + $dbName = AppConfig::get('DB_NAME', ''); + $dbUser = AppConfig::get('DB_USER', ''); + $dbPass = AppConfig::get('DB_PASS', ''); + + if (empty($dbName) || empty($dbUser)) { + echo "Error: Database configuration not found in .env\n"; + exit(1); + } + + $db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass); + + // Initialize service + $service = new HourlySummaryService($db); + + // Run aggregation + echo "Processing hourly summary for date: {$date}\n"; + $result = $service->aggregateForDate($date); + + echo "Success!\n"; + echo "Date: {$result['date']}\n"; + echo "Rows processed: {$result['rows_processed']}\n"; + + exit(0); + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); +} + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6db3d60 --- /dev/null +++ b/composer.json @@ -0,0 +1,23 @@ +{ + "name": "api-btekno/slim-api", + "description": "Slim Framework 4 API for production", + "type": "project", + "license": "proprietary", + "require": { + "php": "^8.2", + "slim/psr7": "^1.6", + "slim/slim": "^4.12", + "vlucas/phpdotenv": "^5.6" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..9ce59c7 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,57 @@ +# Database Migrations + +## Cara Apply Migration + +### 1. Backup Database (PENTING!) +Sebelum menjalankan migration, pastikan untuk backup database terlebih dahulu: + +```bash +mysqldump -u sql_retribusi -p sql_retribusi > backup_$(date +%Y%m%d_%H%M%S).sql +``` + +### 2. Apply Migration + +#### Menggunakan MySQL Command Line: +```bash +mysql -u sql_retribusi -p sql_retribusi < migrations/001_create_audit_logs.sql +``` + +#### Menggunakan phpMyAdmin: +1. Login ke phpMyAdmin +2. Pilih database `sql_retribusi` +3. Klik tab "SQL" +4. Copy-paste isi file `001_create_audit_logs.sql` +5. Klik "Go" untuk execute + +#### Menggunakan MySQL Workbench: +1. Buka MySQL Workbench +2. Connect ke database server +3. Pilih database `sql_retribusi` +4. File โ†’ Run SQL Script +5. Pilih file `001_create_audit_logs.sql` +6. Klik "Run" + +### 3. Verifikasi + +Setelah migration berhasil, verifikasi tabel sudah dibuat: + +```sql +SHOW TABLES LIKE 'audit_logs'; +DESCRIBE audit_logs; +``` + +## Daftar Migration + +### 001_create_audit_logs.sql +- **Tanggal**: 2024-12-28 +- **Deskripsi**: Membuat tabel `audit_logs` untuk tracking semua perubahan data (create/update/delete) +- **Tabel**: `audit_logs` +- **Rollback**: Tidak ada (tabel ini critical untuk audit, tidak boleh dihapus) + +## Catatan Penting + +- **JANGAN** hapus atau modify migration file yang sudah di-apply +- Selalu backup database sebelum apply migration +- Test migration di environment development terlebih dahulu +- Jika terjadi error, restore dari backup dan perbaiki migration file + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..9d56a31 --- /dev/null +++ b/public/index.php @@ -0,0 +1,32 @@ +run(); + diff --git a/src/Bootstrap/AppBootstrap.php b/src/Bootstrap/AppBootstrap.php new file mode 100644 index 0000000..33b2c9e --- /dev/null +++ b/src/Bootstrap/AppBootstrap.php @@ -0,0 +1,34 @@ +addBodyParsingMiddleware(); + + // Add routing middleware + $app->addRoutingMiddleware(); + + // Add error middleware + $app->addErrorMiddleware(true, true, true); + + return $app; + } +} + diff --git a/src/Bootstrap/app.php b/src/Bootstrap/app.php new file mode 100644 index 0000000..33b2c9e --- /dev/null +++ b/src/Bootstrap/app.php @@ -0,0 +1,34 @@ +addBodyParsingMiddleware(); + + // Add routing middleware + $app->addRoutingMiddleware(); + + // Add error middleware + $app->addErrorMiddleware(true, true, true); + + return $app; + } +} + diff --git a/src/Config/AppConfig.php b/src/Config/AppConfig.php new file mode 100644 index 0000000..53d67cc --- /dev/null +++ b/src/Config/AppConfig.php @@ -0,0 +1,41 @@ +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; + } +} + diff --git a/src/Config/app.php b/src/Config/app.php new file mode 100644 index 0000000..120b862 --- /dev/null +++ b/src/Config/app.php @@ -0,0 +1,28 @@ +load(); + } + } +} + diff --git a/src/Middleware/ApiKeyMiddleware.php b/src/Middleware/ApiKeyMiddleware.php new file mode 100644 index 0000000..917148d --- /dev/null +++ b/src/Middleware/ApiKeyMiddleware.php @@ -0,0 +1,52 @@ +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); + } +} + diff --git a/src/Middleware/JwtMiddleware.php b/src/Middleware/JwtMiddleware.php new file mode 100644 index 0000000..6dc5241 --- /dev/null +++ b/src/Middleware/JwtMiddleware.php @@ -0,0 +1,85 @@ +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 + ); + } + } +} + diff --git a/src/Middleware/RoleMiddleware.php b/src/Middleware/RoleMiddleware.php new file mode 100644 index 0000000..d03dfe3 --- /dev/null +++ b/src/Middleware/RoleMiddleware.php @@ -0,0 +1,45 @@ +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); + } +} + diff --git a/src/Modules/Auth/AuthController.php b/src/Modules/Auth/AuthController.php new file mode 100644 index 0000000..dff0edd --- /dev/null +++ b/src/Modules/Auth/AuthController.php @@ -0,0 +1,102 @@ +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 + ); + } + } +} + diff --git a/src/Modules/Auth/AuthRoutes.php b/src/Modules/Auth/AuthRoutes.php new file mode 100644 index 0000000..f140854 --- /dev/null +++ b/src/Modules/Auth/AuthRoutes.php @@ -0,0 +1,49 @@ +group('/auth', function ($group) use ($authController) { + $group->group('/v1', function ($v1Group) use ($authController) { + $v1Group->post('/login', [$authController, 'login']); + }); + }); + } +} + diff --git a/src/Modules/Auth/AuthService.php b/src/Modules/Auth/AuthService.php new file mode 100644 index 0000000..d807d99 --- /dev/null +++ b/src/Modules/Auth/AuthService.php @@ -0,0 +1,103 @@ +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 + ]; + } +} + diff --git a/src/Modules/Health/HealthRoutes.php b/src/Modules/Health/HealthRoutes.php new file mode 100644 index 0000000..c6c5fc2 --- /dev/null +++ b/src/Modules/Health/HealthRoutes.php @@ -0,0 +1,35 @@ +get('/health', function ( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + $data = [ + 'status' => 'ok', + 'time' => time() + ]; + + return ResponseHelper::json($response, $data); + }); + } +} + diff --git a/src/Modules/Health/Routes.php b/src/Modules/Health/Routes.php new file mode 100644 index 0000000..c6c5fc2 --- /dev/null +++ b/src/Modules/Health/Routes.php @@ -0,0 +1,35 @@ +get('/health', function ( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + $data = [ + 'status' => 'ok', + 'time' => time() + ]; + + return ResponseHelper::json($response, $data); + }); + } +} + diff --git a/src/Modules/Retribusi/Dashboard/DashboardController.php b/src/Modules/Retribusi/Dashboard/DashboardController.php new file mode 100644 index 0000000..631194a --- /dev/null +++ b/src/Modules/Retribusi/Dashboard/DashboardController.php @@ -0,0 +1,269 @@ +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 + ); + } + } +} + diff --git a/src/Modules/Retribusi/Dashboard/DashboardRoutes.php b/src/Modules/Retribusi/Dashboard/DashboardRoutes.php new file mode 100644 index 0000000..bbb8b93 --- /dev/null +++ b/src/Modules/Retribusi/Dashboard/DashboardRoutes.php @@ -0,0 +1,55 @@ +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); + }); + }); + } +} + diff --git a/src/Modules/Retribusi/Dashboard/DashboardService.php b/src/Modules/Retribusi/Dashboard/DashboardService.php new file mode 100644 index 0000000..92ccf16 --- /dev/null +++ b/src/Modules/Retribusi/Dashboard/DashboardService.php @@ -0,0 +1,220 @@ +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 + ]; + } +} + diff --git a/src/Modules/Retribusi/Frontend/AuditService.php b/src/Modules/Retribusi/Frontend/AuditService.php new file mode 100644 index 0000000..b7c889f --- /dev/null +++ b/src/Modules/Retribusi/Frontend/AuditService.php @@ -0,0 +1,92 @@ +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'; + } +} + diff --git a/src/Modules/Retribusi/Frontend/GateController.php b/src/Modules/Retribusi/Frontend/GateController.php new file mode 100644 index 0000000..2aeb87d --- /dev/null +++ b/src/Modules/Retribusi/Frontend/GateController.php @@ -0,0 +1,364 @@ +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 + ); + } + } +} diff --git a/src/Modules/Retribusi/Frontend/LocationController.php b/src/Modules/Retribusi/Frontend/LocationController.php new file mode 100644 index 0000000..1391b94 --- /dev/null +++ b/src/Modules/Retribusi/Frontend/LocationController.php @@ -0,0 +1,349 @@ +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 + ); + } + } +} + diff --git a/src/Modules/Retribusi/Frontend/RetribusiReadService.php b/src/Modules/Retribusi/Frontend/RetribusiReadService.php new file mode 100644 index 0000000..7131693 --- /dev/null +++ b/src/Modules/Retribusi/Frontend/RetribusiReadService.php @@ -0,0 +1,143 @@ +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(); + } +} diff --git a/src/Modules/Retribusi/Frontend/RetribusiWriteService.php b/src/Modules/Retribusi/Frontend/RetribusiWriteService.php new file mode 100644 index 0000000..488cdb5 --- /dev/null +++ b/src/Modules/Retribusi/Frontend/RetribusiWriteService.php @@ -0,0 +1,326 @@ +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; + } +} + diff --git a/src/Modules/Retribusi/Frontend/StreamController.php b/src/Modules/Retribusi/Frontend/StreamController.php new file mode 100644 index 0000000..e4ab433 --- /dev/null +++ b/src/Modules/Retribusi/Frontend/StreamController.php @@ -0,0 +1,47 @@ +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() + ] + ); + } +} + diff --git a/src/Modules/Retribusi/Frontend/TariffController.php b/src/Modules/Retribusi/Frontend/TariffController.php new file mode 100644 index 0000000..a61a4db --- /dev/null +++ b/src/Modules/Retribusi/Frontend/TariffController.php @@ -0,0 +1,346 @@ +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 + ); + } + } +} + diff --git a/src/Modules/Retribusi/Ingest/IngestController.php b/src/Modules/Retribusi/Ingest/IngestController.php new file mode 100644 index 0000000..bb1946f --- /dev/null +++ b/src/Modules/Retribusi/Ingest/IngestController.php @@ -0,0 +1,108 @@ +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 + ); + } + } +} diff --git a/src/Modules/Retribusi/Ingest/IngestService.php b/src/Modules/Retribusi/Ingest/IngestService.php new file mode 100644 index 0000000..be617e6 --- /dev/null +++ b/src/Modules/Retribusi/Ingest/IngestService.php @@ -0,0 +1,176 @@ +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()); + } + } +} diff --git a/src/Modules/Retribusi/Realtime/RealtimeController.php b/src/Modules/Retribusi/Realtime/RealtimeController.php new file mode 100644 index 0000000..047d752 --- /dev/null +++ b/src/Modules/Retribusi/Realtime/RealtimeController.php @@ -0,0 +1,200 @@ +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 + ); + } + } +} + diff --git a/src/Modules/Retribusi/Realtime/RealtimeRoutes.php b/src/Modules/Retribusi/Realtime/RealtimeRoutes.php new file mode 100644 index 0000000..ac93e82 --- /dev/null +++ b/src/Modules/Retribusi/Realtime/RealtimeRoutes.php @@ -0,0 +1,54 @@ +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); + }); + }); + } +} + diff --git a/src/Modules/Retribusi/Realtime/RealtimeService.php b/src/Modules/Retribusi/Realtime/RealtimeService.php new file mode 100644 index 0000000..f21420f --- /dev/null +++ b/src/Modules/Retribusi/Realtime/RealtimeService.php @@ -0,0 +1,166 @@ +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 + ]; + } +} + diff --git a/src/Modules/Retribusi/RetribusiRoutes.php b/src/Modules/Retribusi/RetribusiRoutes.php new file mode 100644 index 0000000..15b659b --- /dev/null +++ b/src/Modules/Retribusi/RetribusiRoutes.php @@ -0,0 +1,134 @@ +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); + }); + }); + } +} + diff --git a/src/Modules/Retribusi/Summary/DailySummaryService.php b/src/Modules/Retribusi/Summary/DailySummaryService.php new file mode 100644 index 0000000..874a2c6 --- /dev/null +++ b/src/Modules/Retribusi/Summary/DailySummaryService.php @@ -0,0 +1,157 @@ +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(); + } +} + diff --git a/src/Modules/Retribusi/Summary/HourlySummaryService.php b/src/Modules/Retribusi/Summary/HourlySummaryService.php new file mode 100644 index 0000000..cef5390 --- /dev/null +++ b/src/Modules/Retribusi/Summary/HourlySummaryService.php @@ -0,0 +1,195 @@ +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 + ] + ]; + } +} + diff --git a/src/Modules/Retribusi/Summary/SummaryController.php b/src/Modules/Retribusi/Summary/SummaryController.php new file mode 100644 index 0000000..6de6d53 --- /dev/null +++ b/src/Modules/Retribusi/Summary/SummaryController.php @@ -0,0 +1,253 @@ +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 + ); + } + } +} + diff --git a/src/Modules/Retribusi/Summary/SummaryRoutes.php b/src/Modules/Retribusi/Summary/SummaryRoutes.php new file mode 100644 index 0000000..43e7c7d --- /dev/null +++ b/src/Modules/Retribusi/Summary/SummaryRoutes.php @@ -0,0 +1,68 @@ +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); + }); + }); + } +} + diff --git a/src/Support/Database.php b/src/Support/Database.php new file mode 100644 index 0000000..a69319c --- /dev/null +++ b/src/Support/Database.php @@ -0,0 +1,60 @@ + 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; + } +} + diff --git a/src/Support/Jwt.php b/src/Support/Jwt.php new file mode 100644 index 0000000..8ee82dc --- /dev/null +++ b/src/Support/Jwt.php @@ -0,0 +1,117 @@ + '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)); + } +} + diff --git a/src/Support/Response.php b/src/Support/Response.php new file mode 100644 index 0000000..80ea144 --- /dev/null +++ b/src/Support/Response.php @@ -0,0 +1,32 @@ +getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus($statusCode); + } +} + diff --git a/src/Support/ResponseHelper.php b/src/Support/ResponseHelper.php new file mode 100644 index 0000000..397d778 --- /dev/null +++ b/src/Support/ResponseHelper.php @@ -0,0 +1,31 @@ +getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus($statusCode); + } +} + diff --git a/src/Support/Validator.php b/src/Support/Validator.php new file mode 100644 index 0000000..37241a5 --- /dev/null +++ b/src/Support/Validator.php @@ -0,0 +1,298 @@ + 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; + } +} +