diff --git a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt index 0857638..7703f85 100644 --- a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt +++ b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt @@ -49,4 +49,16 @@ interface ApiService { @GET("api/app-version") suspend fun checkVersion(): ApiResponse + + @GET("api/notifications") + suspend fun getNotifications( + @Query("page") page: Int = 1, + @Query("per_page") perPage: Int = 20 + ): NotificationListResponse + + @POST("api/notifications/{id}/read") + suspend fun markNotificationRead(@Path("id") id: Int): ApiResponse> + + @POST("api/notifications/read-all") + suspend fun markAllNotificationsRead(): ApiResponse> } diff --git a/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt b/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt index 601c2bb..10ca05e 100644 --- a/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt +++ b/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt @@ -25,3 +25,12 @@ data class AppVersionResponse( val force_update: Boolean = false, val release_notes: String = "" ) + +@Serializable +data class NotificationListResponse( + val success: Boolean = true, + val data: List = emptyList(), + val error: String? = null, + val pagination: Pagination? = null, + val unread_count: Int = 0 +) diff --git a/android/app/src/main/java/com/devlab/app/data/model/Notification.kt b/android/app/src/main/java/com/devlab/app/data/model/Notification.kt new file mode 100644 index 0000000..1258ee0 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/model/Notification.kt @@ -0,0 +1,11 @@ +package com.devlab.app.data.model + +@kotlinx.serialization.Serializable +data class AppNotification( + val id: Int = 0, + val title: String = "", + val message: String = "", + val type: String = "info", + val is_read: Int = 0, + val created_at: String = "", +) diff --git a/database/migrations/005_app_config_and_notifications.sql b/database/migrations/005_app_config_and_notifications.sql new file mode 100644 index 0000000..13f0358 --- /dev/null +++ b/database/migrations/005_app_config_and_notifications.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS app_config ( + `key` VARCHAR(64) PRIMARY KEY, + `value` TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO app_config (`key`, `value`) VALUES ('app_version', '1.0.1'); +INSERT IGNORE INTO app_config (`key`, `value`) VALUES ('apk_url', '/sysadmin.apk'); +INSERT IGNORE INTO app_config (`key`, `value`) VALUES ('force_update', '0'); +INSERT IGNORE INTO app_config (`key`, `value`) VALUES ('release_notes', '• Initial release\n• Server management\n• SSH terminal'); + +CREATE TABLE IF NOT EXISTS notifications ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED DEFAULT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + type ENUM('info','warning','success','error') NOT NULL DEFAULT 'info', + is_read TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + read_at DATETIME DEFAULT NULL, + INDEX idx_user_id (user_id), + INDEX idx_is_read (is_read), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/sysadmin.apk b/public/sysadmin.apk index 67265ab..1c28a16 100644 Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ diff --git a/routes/web.php b/routes/web.php index 70ab3ad..9802297 100755 --- a/routes/web.php +++ b/routes/web.php @@ -91,6 +91,10 @@ $router->group(['prefix' => 'admin', 'middleware' => [AuthMiddleware::class, Rol $router->get('/audit', [AdminController::class, 'auditLogs']); $router->get('/security', [AdminController::class, 'securitySettings']); + $router->get('/app-version', [AdminController::class, 'appVersion']); + $router->post('/app-version', [AdminController::class, 'appVersion'], [CSRFMiddleware::class]); + $router->get('/notifications', [AdminController::class, 'notifications']); + $router->post('/notifications/send', [AdminController::class, 'sendNotification'], [CSRFMiddleware::class]); }); $router->get('/api/status', [ApiController::class, 'status']); @@ -104,6 +108,9 @@ $router->post('/api/servers/:id/execute', [ApiController::class, 'executeCommand $router->post('/api/auth/login', [ApiController::class, 'login']); $router->post('/api/auth/register', [ApiController::class, 'register']); $router->get('/api/app-version', [ApiController::class, 'appVersion']); +$router->get('/api/notifications', [ApiController::class, 'notifications']); +$router->post('/api/notifications/:id/read', [ApiController::class, 'markNotificationRead']); +$router->post('/api/notifications/read-all', [ApiController::class, 'markAllNotificationsRead']); $router->get('/api/users', [ApiController::class, 'users']); $router->get('/api/profile', [ApiController::class, 'profile']); $router->put('/api/profile', [ApiController::class, 'updateProfile']); diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index eb86d29..c4e306f 100755 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace ServerManager\Controllers; use ServerManager\Core\App; +use ServerManager\Core\Database; use ServerManager\Core\Session; use ServerManager\Core\Validator; +use ServerManager\Models\Notification; use ServerManager\Models\User; use ServerManager\Services\AuditService; @@ -210,4 +212,79 @@ class AdminController 'title' => 'Security Settings - ServerManager', ]); } + + public function appVersion(): void + { + $this->requireSuperAdmin(); + + $db = Database::getInstance(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $fields = ['app_version', 'apk_url', 'force_update', 'release_notes']; + foreach ($fields as $field) { + if (isset($_POST[$field])) { + $db->update('app_config', ['value' => $_POST[$field]], '`key` = ?', [$field]); + } + } + $this->auditService->log('app_version_updated', 'App version config updated'); + $this->view->json(['success' => true, 'message' => 'App version updated successfully.']); + return; + } + + $configs = $db->fetchAll('SELECT * FROM app_config'); + $config = []; + foreach ($configs as $row) { + $config[$row['key']] = $row['value']; + } + + $this->view->display('admin.app_version', [ + 'title' => 'App Version - ServerManager', + 'config' => $config, + ]); + } + + public function notifications(): void + { + $this->requireSuperAdmin(); + + $notificationModel = new Notification(); + $page = (int) ($_GET['page'] ?? 1); + $allNotifications = $notificationModel->getAll($page, 20); + + $userModel = new User(); + $users = $userModel->getAll(1, 200); + + $this->view->display('admin.notifications', [ + 'title' => 'Notifications - ServerManager', + 'notifications' => $allNotifications, + 'users' => $users, + ]); + } + + public function sendNotification(): void + { + $this->requireSuperAdmin(); + + $title = $_POST['title'] ?? ''; + $message = $_POST['message'] ?? ''; + $type = $_POST['type'] ?? 'info'; + $userId = !empty($_POST['user_id']) ? (int) $_POST['user_id'] : null; + + if (empty($title) || empty($message)) { + $this->view->json(['success' => false, 'message' => 'Title and message are required.']); + return; + } + + $notificationModel = new Notification(); + $notificationModel->create([ + 'user_id' => $userId, + 'title' => $title, + 'message' => $message, + 'type' => $type, + ]); + + $this->auditService->log('notification_sent', "Notification: {$title}" . ($userId ? " to user #{$userId}" : ' (all users)')); + + $this->view->json(['success' => true, 'message' => 'Notification sent successfully.']); + } } diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index 94aaf3c..a283c5c 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -468,17 +468,68 @@ class ApiController public function appVersion(): void { + $db = \ServerManager\Core\Database::getInstance(); + $configs = $db->fetchAll('SELECT * FROM app_config'); + $config = []; + foreach ($configs as $row) { + $config[$row['key']] = $row['value']; + } + $this->view->json([ 'success' => true, 'data' => [ - 'version' => '1.0.1', - 'apk_url' => '/sysadmin.apk', - 'force_update' => false, - 'release_notes' => '• Gráficos de métricas en tiempo real\n• Nuevo diseño con animaciones\n• Modo oscuro\n• Perfil de usuario\n• Mejoras de rendimiento', + 'version' => $config['app_version'] ?? '1.0.0', + 'apk_url' => $config['apk_url'] ?? '/sysadmin.apk', + 'force_update' => ($config['force_update'] ?? '0') === '1', + 'release_notes' => $config['release_notes'] ?? '', ], ]); } + public function notifications(): void + { + $user = $this->authenticateRequest(); + + $notificationModel = new \ServerManager\Models\Notification(); + $page = (int) ($_GET['page'] ?? 1); + $perPage = (int) ($_GET['per_page'] ?? 20); + + $result = $notificationModel->getForUser((int) $user['id'], $page, $perPage); + $unreadCount = $notificationModel->getUnreadCount((int) $user['id']); + + $this->view->json([ + 'success' => true, + 'data' => $result['data'], + 'pagination' => [ + 'page' => $result['page'], + 'per_page' => $result['per_page'], + 'total' => $result['total'], + 'total_pages' => $result['total_pages'], + ], + 'unread_count' => $unreadCount, + ]); + } + + public function markNotificationRead(int $id): void + { + $user = $this->authenticateRequest(); + + $notificationModel = new \ServerManager\Models\Notification(); + $notificationModel->markAsRead($id, (int) $user['id']); + + $this->view->json(['success' => true]); + } + + public function markAllNotificationsRead(): void + { + $user = $this->authenticateRequest(); + + $notificationModel = new \ServerManager\Models\Notification(); + $notificationModel->markAllAsRead((int) $user['id']); + + $this->view->json(['success' => true]); + } + public function login(): void { $input = json_decode(file_get_contents('php://input'), true) ?? $_POST; diff --git a/src/Models/Notification.php b/src/Models/Notification.php new file mode 100644 index 0000000..a45a500 --- /dev/null +++ b/src/Models/Notification.php @@ -0,0 +1,99 @@ +db = Database::getInstance(); + } + + public function create(array $data): int + { + $data['created_at'] = date('Y-m-d H:i:s'); + return $this->db->insert('notifications', $data); + } + + public function findById(int $id): ?array + { + return $this->db->fetch('SELECT * FROM notifications WHERE id = ?', [$id]); + } + + public function getForUser(int $userId, int $page = 1, int $perPage = 20): array + { + $offset = ($page - 1) * $perPage; + $total = $this->db->fetch( + "SELECT COUNT(*) as total FROM notifications WHERE user_id IS NULL OR user_id = ?", + [$userId] + ); + $items = $this->db->fetchAll( + "SELECT * FROM notifications WHERE user_id IS NULL OR user_id = ? + ORDER BY created_at DESC LIMIT ? OFFSET ?", + [$userId, $perPage, $offset] + ); + return [ + 'data' => $items, + 'total' => (int) ($total['total'] ?? 0), + 'page' => $page, + 'per_page' => $perPage, + 'total_pages' => (int) ceil((int) ($total['total'] ?? 0) / $perPage), + ]; + } + + public function getUnreadCount(int $userId): int + { + $result = $this->db->fetch( + "SELECT COUNT(*) as total FROM notifications + WHERE (user_id IS NULL OR user_id = ?) AND is_read = 0", + [$userId] + ); + return (int) ($result['total'] ?? 0); + } + + public function markAsRead(int $id, int $userId): void + { + $this->db->update( + 'notifications', + ['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')], + 'id = ? AND (user_id IS NULL OR user_id = ?)', + [$id, $userId] + ); + } + + public function markAllAsRead(int $userId): void + { + $this->db->update( + 'notifications', + ['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')], + '(user_id IS NULL OR user_id = ?) AND is_read = 0', + [$userId] + ); + } + + public function getAll(int $page = 1, int $perPage = 20): array + { + $offset = ($page - 1) * $perPage; + $total = $this->db->fetch("SELECT COUNT(*) as total FROM notifications"); + $items = $this->db->fetchAll( + "SELECT n.*, u.username as target_username + FROM notifications n + LEFT JOIN users u ON n.user_id = u.id + ORDER BY n.created_at DESC LIMIT ? OFFSET ?", + [$perPage, $offset] + ); + return [ + 'data' => $items, + 'total' => (int) ($total['total'] ?? 0), + 'page' => $page, + 'per_page' => $perPage, + 'total_pages' => (int) ceil((int) ($total['total'] ?? 0) / $perPage), + ]; + } +} diff --git a/views/admin/app_version.php b/views/admin/app_version.php new file mode 100644 index 0000000..c38de5f --- /dev/null +++ b/views/admin/app_version.php @@ -0,0 +1,86 @@ + + + + +
+
+
+ + +
+ + + Semantic version (X.Y.Z). Must be higher than previous to trigger updates. +
+ +
+ + + Path or URL to the APK file. +
+ +
+ + + If forced, users cannot dismiss the update dialog. +
+ +
+ + + One change per line. These will be shown in the update dialog on Android. +
+ +
+ +
+
+
+
+ + diff --git a/views/admin/notifications.php b/views/admin/notifications.php new file mode 100644 index 0000000..6465f9b --- /dev/null +++ b/views/admin/notifications.php @@ -0,0 +1,175 @@ + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimeTypeTitleMessageTargetStatus
No notifications sent yet.
+ + + + 80 ? '...' : '' ?>All Users' ?> + + + +
+
+ + 1): ?> + + +
+
+ + + + diff --git a/views/layouts/main.php b/views/layouts/main.php index b5ee07e..9929213 100755 --- a/views/layouts/main.php +++ b/views/layouts/main.php @@ -66,6 +66,18 @@ Security + +