getMessage()); } try { // Ensure default sample students if no students exist $student = Database::selectOne("SELECT id FROM users WHERE role = 'student' LIMIT 1"); if (!$student) { $sampleStudents = [ ['name' => 'أحمد محمد الغويري', 'phone' => '962790000002'], ['name' => 'عمر بني صخر (علمي)', 'phone' => '962790000003'], ['name' => 'سارة المشاقبة (توجيهي 2008)', 'phone' => '962790000004'] ]; foreach ($sampleStudents as $s) { $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)); $phoneHash = Security::blindIndex($s['phone']); $encPhone = Security::encrypt($s['phone']); $encName = Security::encrypt($s['name']); $pwd = password_hash('Saqel@2026', PASSWORD_BCRYPT); Database::insert( "INSERT INTO users (uuid, full_name, phone_number, phone_hash, password_hash, role, status, token_version) VALUES (?, ?, ?, ?, ?, 'student', 'active', 1)", [$uuid, $encName, $encPhone, $phoneHash, $pwd] ); } } } catch (\Throwable $e) { error_log("Seed students note: " . $e->getMessage()); } } /** * List all active conversations and available contacts (Student or Teacher) * GET /api/chat/conversations */ public function getConversations(Request $request, Response $response): void { self::ensureSeedUsers(); $userId = (int)$request->user_id; // 1. Fetch all other users in the system $users = []; try { $users = 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 != ? ORDER BY u.id DESC LIMIT 100 ", [$userId]); } catch (\Throwable $e) { error_log("Users select in getConversations error: " . $e->getMessage()); } $conversations = []; foreach ($users as $row) { $otherId = (int)$row['user_id']; // Get latest message between $userId and $otherId $lastMsg = null; $unreadCount = 0; try { $lastMsg = Database::selectOne(" SELECT message, message_type, created_at FROM chat_messages WHERE (sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?) ORDER BY id DESC LIMIT 1 ", [$userId, $otherId, $otherId, $userId]); $unreadRow = Database::selectOne(" SELECT COUNT(*) as cnt FROM chat_messages WHERE sender_id = ? AND receiver_id = ? AND is_read = 0 ", [$otherId, $userId]); $unreadCount = (int)($unreadRow['cnt'] ?? 0); } catch (\Throwable $e) { // Ignore per-user message query issue if any } $rawName = (string)($row['encrypted_name'] ?? ''); $decryptedName = $rawName; try { $dec = Security::decrypt($rawName); if (!empty($dec)) { $decryptedName = $dec; } } catch (\Throwable $e) { $decryptedName = $rawName; } $conversations[] = [ 'user_id' => $otherId, 'user_uuid' => $row['user_uuid'] ?? '', 'full_name' => $decryptedName ?: ($row['user_role'] === 'teacher' ? 'الأستاذ حمزة الغويري' : 'طالب صَقِل'), 'role' => $row['user_role'] ?? 'student', 'specialization' => $row['specialization'] ?? ($row['user_role'] === 'teacher' ? 'الرياضيات العلمي' : null), 'last_message' => $lastMsg['message'] ?? ($row['user_role'] === 'teacher' ? 'متاح للإجابة عن استفساراتك' : 'طالب جديد — اضغط لبدء المحادثة'), 'last_message_type' => $lastMsg['message_type'] ?? 'text', 'last_message_at' => $lastMsg['created_at'] ?? null, 'unread_count' => $unreadCount, ]; } // Sort: users with active messages first, then by last_message_at DESC usort($conversations, function ($a, $b) { if ($a['last_message_at'] && !$b['last_message_at']) return -1; if (!$a['last_message_at'] && $b['last_message_at']) return 1; if ($a['last_message_at'] && $b['last_message_at']) { return strcmp($b['last_message_at'], $a['last_message_at']); } return $b['user_id'] <=> $a['user_id']; }); $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, or fallback to counterpart role automatically $receiver = Database::selectOne("SELECT id, role FROM users WHERE id = ? LIMIT 1", [$receiverId]); if (!$receiver) { $targetRole = ($request->role === 'teacher') ? 'student' : 'teacher'; $fallbackReceiver = Database::selectOne("SELECT id, role FROM users WHERE role = ? ORDER BY id ASC LIMIT 1", [$targetRole]); if (!$fallbackReceiver) { self::ensureSeedUsers(); $fallbackReceiver = Database::selectOne("SELECT id, role FROM users WHERE role = ? ORDER BY id ASC LIMIT 1", [$targetRole]); } if ($fallbackReceiver) { $receiverId = (int)$fallbackReceiver['id']; $receiver = $fallbackReceiver; } else { $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] ); $msgPayload = [ 'id' => $messageId, 'uuid' => $uuid, 'sender_id' => $userId, 'receiver_id' => $receiverId, 'is_mine' => false, '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'), ]; // Push directly to Workerman WebSocket server for instant 0ms live broadcast to receiver self::pushToWorkerman('push_chat', $receiverId, $msgPayload); $response->status(201)->json([ 'status' => 'success', 'message' => 'تم إرسال الرسالة بنجاح', 'data' => array_merge($msgPayload, ['is_mine' => true]) ]); } /** * Push internal event to Workerman's internal HTTP server (port 4241) */ public static function pushToWorkerman(string $action, int $targetUserId, array $payload): bool { try { $ch = curl_init('http://127.0.0.1:4241'); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ 'action' => $action, 'target_user_id' => $targetUserId, 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE) ])); curl_setopt($ch, CURLOPT_TIMEOUT_MS, 400); // Fast non-blocking timeout curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_exec($ch); curl_close($ch); return true; } catch (\Throwable $e) { return false; } } /** * 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) ] ]); } }