feat: Full API-driven Student-Teacher Chat architecture with chat_messages table and endpoints
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
<?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
|
||||
{
|
||||
/**
|
||||
* List all active conversations for the authenticated user (Student or Teacher)
|
||||
* GET /api/chat/conversations
|
||||
*/
|
||||
public function getConversations(Request $request, Response $response): void
|
||||
{
|
||||
$userId = $request->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)
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user