Files
saqel/backend/app/Controllers/ChatController.php
T

367 lines
14 KiB
PHP

<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Database;
use App\Core\Security;
use App\Core\Validator;
class ChatController
{
/**
* Zero Mock Policy - Only real database users are queried
*/
private static function ensureSeedUsers(): void
{
// No automatic demo users or students insertion
}
/**
* 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)
]
]);
}
}