From 12f00a05b690ca618ff8e92c49f3297c4e6dbf42 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 27 Aug 2026 00:05:30 +0300 Subject: [PATCH] feat: Full API-driven Student-Teacher Chat architecture with chat_messages table and endpoints --- backend/app/Controllers/ChatController.php | 317 +++++++++++++++++++++ backend/database_schema.sql | 31 +- backend/public/index.php | 6 + 3 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 backend/app/Controllers/ChatController.php diff --git a/backend/app/Controllers/ChatController.php b/backend/app/Controllers/ChatController.php new file mode 100644 index 0000000..346f620 --- /dev/null +++ b/backend/app/Controllers/ChatController.php @@ -0,0 +1,317 @@ +user_id; + + // Find all distinct other parties in conversations + $sql = " + SELECT + u.id as user_id, + u.uuid as user_uuid, + u.full_name as encrypted_name, + u.role as user_role, + tp.specialization, + m.last_message, + m.last_message_type, + m.last_message_at, + COALESCE(unread.unread_count, 0) as unread_count + FROM ( + SELECT + CASE WHEN sender_id = ? THEN receiver_id ELSE sender_id END as other_user_id, + message as last_message, + message_type as last_message_type, + created_at as last_message_at, + ROW_NUMBER() OVER ( + PARTITION BY (CASE WHEN sender_id = ? THEN receiver_id ELSE sender_id END) + ORDER BY created_at DESC + ) as rn + FROM chat_messages + WHERE sender_id = ? OR receiver_id = ? + ) m + JOIN users u ON u.id = m.other_user_id + LEFT JOIN teacher_profiles tp ON tp.user_id = u.id + LEFT JOIN ( + SELECT sender_id, COUNT(*) as unread_count + FROM chat_messages + WHERE receiver_id = ? AND is_read = 0 + GROUP BY sender_id + ) unread ON unread.sender_id = u.id + WHERE m.rn = 1 + ORDER BY m.last_message_at DESC + "; + + try { + $rows = Database::select($sql, [$userId, $userId, $userId, $userId, $userId]); + } catch (\Exception $e) { + // Fallback for MySQL setups without window functions + $rows = Database::select(" + SELECT + u.id as user_id, + u.uuid as user_uuid, + u.full_name as encrypted_name, + u.role as user_role, + tp.specialization + FROM users u + LEFT JOIN teacher_profiles tp ON tp.user_id = u.id + WHERE u.id IN ( + SELECT DISTINCT CASE WHEN sender_id = ? THEN receiver_id ELSE sender_id END + FROM chat_messages + WHERE sender_id = ? OR receiver_id = ? + ) + ", [$userId, $userId, $userId]); + } + + $conversations = array_map(function ($row) { + $rawName = (string)($row['encrypted_name'] ?? ''); + $decryptedName = Security::decrypt($rawName) ?: $rawName; + + return [ + 'user_id' => (int)$row['user_id'], + 'user_uuid' => $row['user_uuid'] ?? '', + 'full_name' => $decryptedName ?: ($row['user_role'] === 'teacher' ? 'أستاذ المادة' : 'طالب صَقِل'), + 'role' => $row['user_role'] ?? 'student', + 'specialization' => $row['specialization'] ?? null, + 'last_message' => $row['last_message'] ?? '', + 'last_message_type' => $row['last_message_type'] ?? 'text', + 'last_message_at' => $row['last_message_at'] ?? null, + 'unread_count' => (int)($row['unread_count'] ?? 0), + ]; + }, $rows); + + $response->json([ + 'status' => 'success', + 'data' => $conversations + ]); + } + + /** + * Get chat message history between authenticated user and another user + * GET /api/chat/messages?other_user_id=123&course_id=456 + */ + public function getMessages(Request $request, Response $response): void + { + $userId = $request->user_id; + $queryParams = $request->getQueryParams(); + + $otherUserId = (int)($queryParams['other_user_id'] ?? $queryParams['user_id'] ?? 0); + $courseId = !empty($queryParams['course_id']) ? (int)$queryParams['course_id'] : null; + $limit = min(100, max(1, (int)($queryParams['limit'] ?? 50))); + + if (!$otherUserId) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'معرف الطرف الآخر (other_user_id) مطلوب' + ]); + return; + } + + // Fetch other user info + $otherUser = Database::selectOne("SELECT id, uuid, full_name, role FROM users WHERE id = ? LIMIT 1", [$otherUserId]); + if (!$otherUser) { + $response->status(404)->json(['status' => 'error', 'message' => 'المستخدم الآخر غير موجود']); + return; + } + + $rawOtherName = (string)($otherUser['full_name'] ?? ''); + $otherUserName = Security::decrypt($rawOtherName) ?: $rawOtherName; + + // Fetch messages bidirectional + $sql = " + SELECT id, uuid, sender_id, receiver_id, course_id, lesson_id, message, message_type, media_url, is_read, read_at, created_at + FROM chat_messages + WHERE ((sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)) + "; + $params = [$userId, $otherUserId, $otherUserId, $userId]; + + if ($courseId) { + $sql .= " AND (course_id = ? OR course_id IS NULL)"; + $params[] = $courseId; + } + + $sql .= " ORDER BY created_at ASC LIMIT ?"; + $params[] = $limit; + + $messages = Database::select($sql, $params); + + // Mark unread messages sent by other user to me as read + Database::query( + "UPDATE chat_messages SET is_read = 1, read_at = NOW() WHERE sender_id = ? AND receiver_id = ? AND is_read = 0", + [$otherUserId, $userId] + ); + + $formattedMessages = array_map(function ($msg) use ($userId) { + return [ + 'id' => (int)$msg['id'], + 'uuid' => $msg['uuid'], + 'is_mine' => ((int)$msg['sender_id'] === (int)$userId), + 'sender_id' => (int)$msg['sender_id'], + 'receiver_id' => (int)$msg['receiver_id'], + 'course_id' => $msg['course_id'] ? (int)$msg['course_id'] : null, + 'lesson_id' => $msg['lesson_id'] ? (int)$msg['lesson_id'] : null, + 'message' => $msg['message'], + 'message_type' => $msg['message_type'], + 'media_url' => $msg['media_url'], + 'is_read' => (bool)$msg['is_read'], + 'read_at' => $msg['read_at'], + 'created_at' => $msg['created_at'], + ]; + }, $messages); + + $response->json([ + 'status' => 'success', + 'data' => [ + 'other_user' => [ + 'id' => (int)$otherUser['id'], + 'uuid' => $otherUser['uuid'], + 'full_name' => $otherUserName, + 'role' => $otherUser['role'], + ], + 'messages' => $formattedMessages + ] + ]); + } + + /** + * Send a new chat message (Text, Voice Note, Image, File) + * POST /api/chat/messages + */ + public function sendMessage(Request $request, Response $response): void + { + $userId = $request->user_id; + $body = $request->getBody(); + + $validator = new Validator(); + $isValid = $validator->validate($body, [ + 'receiver_id' => 'required', + 'message' => 'required' + ]); + + if (!$isValid) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'معرف المستقبل ونص الرسالة مطلوبان', + 'errors' => $validator->getErrors() + ]); + return; + } + + $receiverId = (int)$body['receiver_id']; + $messageText = trim((string)$body['message']); + $messageType = (string)($body['message_type'] ?? 'text'); + $mediaUrl = !empty($body['media_url']) ? trim((string)$body['media_url']) : null; + $courseId = !empty($body['course_id']) ? (int)$body['course_id'] : null; + $lessonId = !empty($body['lesson_id']) ? (int)$body['lesson_id'] : null; + + if (!in_array($messageType, ['text', 'voice', 'image', 'file'], true)) { + $messageType = 'text'; + } + + // Validate receiver exists + $receiver = Database::selectOne("SELECT id, role FROM users WHERE id = ? LIMIT 1", [$receiverId]); + if (!$receiver) { + $response->status(404)->json([ + 'status' => 'error', + 'message' => 'المستخدم المستلم غير موجود' + ]); + return; + } + + $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + + $messageId = Database::insert( + "INSERT INTO chat_messages (uuid, sender_id, receiver_id, course_id, lesson_id, message, message_type, media_url, is_read) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)", + [$uuid, $userId, $receiverId, $courseId, $lessonId, $messageText, $messageType, $mediaUrl] + ); + + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'تم إرسال الرسالة بنجاح', + 'data' => [ + 'id' => $messageId, + 'uuid' => $uuid, + 'sender_id' => $userId, + 'receiver_id' => $receiverId, + 'is_mine' => true, + 'course_id' => $courseId, + 'lesson_id' => $lessonId, + 'message' => $messageText, + 'message_type' => $messageType, + 'media_url' => $mediaUrl, + 'is_read' => false, + 'created_at' => date('Y-m-d H:i:s'), + ] + ]); + } + + /** + * Mark all messages in a conversation as read + * POST /api/chat/read + */ + public function markAsRead(Request $request, Response $response): void + { + $userId = $request->user_id; + $body = $request->getBody(); + + $senderId = (int)($body['sender_id'] ?? 0); + if (!$senderId) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'معرف المرسل مطلوب' + ]); + return; + } + + Database::query( + "UPDATE chat_messages SET is_read = 1, read_at = NOW() WHERE sender_id = ? AND receiver_id = ? AND is_read = 0", + [$senderId, $userId] + ); + + $response->json([ + 'status' => 'success', + 'message' => 'تم تحديث حالة القراءة' + ]); + } + + /** + * Get total unread count for badge notification + * GET /api/chat/unread-count + */ + public function getUnreadCount(Request $request, Response $response): void + { + $userId = $request->user_id; + + $row = Database::selectOne( + "SELECT COUNT(*) as unread_total FROM chat_messages WHERE receiver_id = ? AND is_read = 0", + [$userId] + ); + + $response->json([ + 'status' => 'success', + 'data' => [ + 'unread_count' => (int)($row['unread_total'] ?? 0) + ] + ]); + } +} diff --git a/backend/database_schema.sql b/backend/database_schema.sql index ea2a8f1..a9c8540 100644 --- a/backend/database_schema.sql +++ b/backend/database_schema.sql @@ -8,7 +8,7 @@ SET FOREIGN_KEY_CHECKS = 0; -- ------------------------------------------------------------------------------ -- DROP ALL EXISTING TABLES IN REVERSE ORDER TO PREVENT FOREIGN KEY CONFLICTS --- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `chat_messages`; DROP TABLE IF EXISTS `otp_verifications`; DROP TABLE IF EXISTS `user_devices`; DROP TABLE IF EXISTS `voice_notes`; @@ -273,4 +273,33 @@ CREATE TABLE `otp_verifications` ( KEY `idx_otp_phone_hash` (`phone_hash`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ------------------------------------------------------------------------------ +-- 15. Table: chat_messages (المحادثات المباشرة بين الطلاب والمعلمين) +-- ------------------------------------------------------------------------------ +CREATE TABLE `chat_messages` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `sender_id` BIGINT UNSIGNED NOT NULL, + `receiver_id` BIGINT UNSIGNED NOT NULL, + `course_id` BIGINT UNSIGNED DEFAULT NULL, + `lesson_id` BIGINT UNSIGNED DEFAULT NULL, + `message` TEXT NOT NULL, + `message_type` ENUM('text', 'voice', 'image', 'file') NOT NULL DEFAULT 'text', + `media_url` VARCHAR(500) DEFAULT NULL, + `is_read` TINYINT(1) NOT NULL DEFAULT 0, + `read_at` TIMESTAMP NULL DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_chat_messages_uuid` (`uuid`), + KEY `idx_chat_sender_receiver` (`sender_id`, `receiver_id`), + KEY `idx_chat_receiver_unread` (`receiver_id`, `is_read`), + KEY `idx_chat_course` (`course_id`), + KEY `idx_chat_created_at` (`created_at`), + CONSTRAINT `fk_chat_sender` FOREIGN KEY (`sender_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_chat_receiver` FOREIGN KEY (`receiver_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_chat_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_chat_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + SET FOREIGN_KEY_CHECKS = 1; diff --git a/backend/public/index.php b/backend/public/index.php index 34efff7..3220342 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -66,6 +66,12 @@ $router->get('/api/teacher/courses', [\App\Controllers\TeacherController: $router->post('/api/teacher/courses', [\App\Controllers\TeacherController::class, 'addCourse'], [\App\Middlewares\AuthMiddleware::class]); $router->post('/api/teacher/lessons', [\App\Controllers\TeacherController::class, 'addLesson'], [\App\Middlewares\AuthMiddleware::class]); +// Student & Teacher Chat Routes (API-Driven, Authenticated) +$router->get('/api/chat/conversations', [\App\Controllers\ChatController::class, 'getConversations'], [\App\Middlewares\AuthMiddleware::class]); +$router->get('/api/chat/messages', [\App\Controllers\ChatController::class, 'getMessages'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/chat/messages', [\App\Controllers\ChatController::class, 'sendMessage'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/chat/read', [\App\Controllers\ChatController::class, 'markAsRead'], [\App\Middlewares\AuthMiddleware::class]); +$router->get('/api/chat/unread-count', [\App\Controllers\ChatController::class, 'getUnreadCount'], [\App\Middlewares\AuthMiddleware::class]); // 5. Dispatch the request $router->dispatch($request, $response);