From e944d92aac2b0a40cc8654b7f7cb3be16fe26c52 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 26 Aug 2026 17:34:51 +0300 Subject: [PATCH] Implement Enterprise Logic (Redis, Phone Auth, Full Schema) --- backend/.env.example | 38 --- backend/app/Controllers/AuthController.php | 206 +++++++++++++ backend/app/Controllers/TeacherController.php | 95 ++++++ backend/app/Core/RedisClient.php | 62 ++++ backend/app/Core/Security.php | 2 +- backend/app/Middlewares/AuthMiddleware.php | 19 +- .../app/Middlewares/RateLimitMiddleware.php | 72 ++--- backend/database_schema.sql | 271 ++++++++++++++++++ backend/public/index.php | 11 +- backend/public/migrate.php | 17 ++ 10 files changed, 707 insertions(+), 86 deletions(-) delete mode 100644 backend/.env.example create mode 100644 backend/app/Controllers/AuthController.php create mode 100644 backend/app/Controllers/TeacherController.php create mode 100644 backend/app/Core/RedisClient.php create mode 100644 backend/database_schema.sql create mode 100644 backend/public/migrate.php diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index a93fdbd..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,38 +0,0 @@ -# Application Settings -APP_NAME=Nabeh -APP_ENV=development -APP_DEBUG=true -APP_URL=http://localhost:8000 - -# Main Master Database Configuration -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=nabeh_master -DB_USERNAME=root -DB_PASSWORD= - -# AI Model Configuration -GEMINI_API_KEY= -ELEVENLABS_API_KEY= -ELEVENLABS_VOICE_ID=EXAVITQu4vr4xnSDxMaL - -# Messaging Gateway Settings -WHATSAPP_GATEWAY_URL=http://localhost:3722 - -# OWASP Security Settings -# Generate a secure 32-byte (256-bit) key for AES encryption -ENCRYPTION_KEY=d3b07384d113edec49eaa6238ad5ff00f898129dfdeca34289adcd11a00a89d1 -# Secret key/salt for blind index hashes -HMAC_SALT=nabeh_secure_blind_index_salt_key_123 -# Secret key for JWT signatures -JWT_SECRET=nabeh_jwt_secret_signature_key_987654 - -# Redis Settings (Optional caching - falls back to DB if disabled/unavailable) -REDIS_ENABLED=false -REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 -REDIS_PASSWORD= -REDIS_DB=0 - - - diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php new file mode 100644 index 0000000..7bbf246 --- /dev/null +++ b/backend/app/Controllers/AuthController.php @@ -0,0 +1,206 @@ +getBody(); + + $validator = new Validator(); + $isValid = $validator->validate($body, [ + 'full_name' => 'required', + 'phone_number' => 'required', + 'password' => 'required|min:8', + 'role' => 'required' + ]); + + if (!$isValid) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'Validation failed', + 'errors' => $validator->getErrors() + ]); + return; + } + + $phone = $body['phone_number']; + $role = $body['role']; + + if (!in_array($role, ['student', 'teacher', 'guardian'])) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'Invalid role specified' + ]); + return; + } + + $phoneHash = Security::blindIndex($phone); + $existing = Database::selectOne("SELECT id FROM users WHERE phone_hash = ? LIMIT 1", [$phoneHash]); + + if ($existing) { + $response->status(409)->json([ + 'status' => 'error', + 'message' => 'Phone number is already registered' + ]); + return; + } + + $passwordHash = Security::hashPassword($body['password']); + + // Generate UUID + $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) + ); + + $userId = Database::insert( + "INSERT INTO users (uuid, full_name, phone_number, phone_hash, password_hash, role, status) VALUES (?, ?, ?, ?, ?, ?, 'active')", + [$uuid, $body['full_name'], $phone, $phoneHash, $passwordHash, $role] + ); + + $this->generateSessionAndRespond($userId, $uuid, $role, $response, "User registered successfully"); + } + + /** + * Login using Phone Number and Password + */ + public function login(Request $request, Response $response): void + { + $body = $request->getBody(); + $validator = new Validator(); + + if (!$validator->validate($body, [ + 'phone_number' => 'required', + 'password' => 'required' + ])) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'Validation failed', + 'errors' => $validator->getErrors() + ]); + return; + } + + $phoneHash = Security::blindIndex($body['phone_number']); + $user = Database::selectOne("SELECT * FROM users WHERE phone_hash = ? LIMIT 1", [$phoneHash]); + + if (!$user || !Security::verifyPassword($body['password'], $user['password_hash'])) { + $response->status(401)->json([ + 'status' => 'error', + 'message' => 'Invalid phone number or password' + ]); + return; + } + + if ($user['status'] === 'suspended') { + $response->status(403)->json([ + 'status' => 'error', + 'message' => 'Account is suspended' + ]); + return; + } + + $this->generateSessionAndRespond($user['id'], $user['uuid'], $user['role'], $response, "Login successful"); + } + + /** + * Request OTP for phone verification + */ + public function requestOtp(Request $request, Response $response): void + { + $body = $request->getBody(); + if (empty($body['phone_number'])) { + $response->status(400)->json(['status' => 'error', 'message' => 'Phone number is required']); + return; + } + + $phone = $body['phone_number']; + $otp = (string)random_int(100000, 999999); + + // Save OTP to Redis for 5 minutes + $redis = RedisClient::getInstance(); + $redis->setex('otp:' . $phone, 300, $otp); + + // TODO: Integrate SMS gateway here to actually send the OTP via SMS + + $response->json([ + 'status' => 'success', + 'message' => 'OTP sent successfully (Simulated: ' . $otp . ')' + ]); + } + + /** + * Common method to generate JWT and save session to Redis + */ + private function generateSessionAndRespond(int $userId, string $uuid, string $role, Response $response, string $msg): void + { + $payload = [ + 'user_id' => $userId, + 'uuid' => $uuid, + 'role' => $role + ]; + + $token = Security::generateJWT($payload); + + // Store session in Redis (Active for 30 days) + try { + $redis = RedisClient::getInstance(); + $redis->setex("session:{$userId}:{$token}", 30 * 86400, "active"); + } catch (\Exception $e) { + error_log("Failed to save session to Redis: " . $e->getMessage()); + } + + $response->status(200)->json([ + 'status' => 'success', + 'message' => $msg, + 'data' => [ + 'token' => $token, + 'user' => [ + 'uuid' => $uuid, + 'role' => $role + ] + ] + ]); + } + + /** + * Get Current User Data + */ + public function me(Request $request, Response $response): void + { + $userId = $request->user_id; + + $user = Database::selectOne( + "SELECT uuid, full_name, role, status, created_at FROM users WHERE id = ? LIMIT 1", + [$userId] + ); + + if (!$user) { + $response->status(404)->json([ + 'status' => 'error', + 'message' => 'User not found' + ]); + return; + } + + $response->json([ + 'status' => 'success', + 'data' => $user + ]); + } +} diff --git a/backend/app/Controllers/TeacherController.php b/backend/app/Controllers/TeacherController.php new file mode 100644 index 0000000..f0925e5 --- /dev/null +++ b/backend/app/Controllers/TeacherController.php @@ -0,0 +1,95 @@ +role !== 'teacher' && $request->role !== 'admin') { + $response->status(403)->json([ + 'status' => 'error', + 'message' => 'Forbidden: Only teachers can add courses' + ]); + return; + } + + $body = $request->getBody(); + $title = $body['title'] ?? ''; + $description = $body['description'] ?? ''; + + if (empty($title)) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'Course title is required' + ]); + return; + } + + $courseId = Database::insert( + "INSERT INTO courses (teacher_id, title, description) VALUES (?, ?, ?)", + [$request->user_id, $title, $description] + ); + + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'Course added successfully', + 'data' => [ + 'course_id' => $courseId + ] + ]); + } + + public function addLesson(Request $request, Response $response): void + { + if ($request->role !== 'teacher' && $request->role !== 'admin') { + $response->status(403)->json([ + 'status' => 'error', + 'message' => 'Forbidden: Only teachers can add lessons' + ]); + return; + } + + $body = $request->getBody(); + $courseId = $body['course_id'] ?? null; + $title = $body['title'] ?? ''; + $content = $body['content'] ?? ''; + $videoUrl = $body['video_url'] ?? null; + $orderNum = $body['order_num'] ?? 1; + + if (!$courseId || empty($title) || empty($content)) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'Course ID, title, and content are required' + ]); + return; + } + + // Verify the course belongs to the teacher + $course = Database::selectOne("SELECT id, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]); + if (!$course || ($course['teacher_id'] != $request->user_id && $request->role !== 'admin')) { + $response->status(403)->json([ + 'status' => 'error', + 'message' => 'Forbidden: You do not own this course' + ]); + return; + } + + $lessonId = Database::insert( + "INSERT INTO lessons (course_id, title, content, video_url, order_num) VALUES (?, ?, ?, ?, ?)", + [$courseId, $title, $content, $videoUrl, $orderNum] + ); + + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'Lesson added successfully', + 'data' => [ + 'lesson_id' => $lessonId + ] + ]); + } +} diff --git a/backend/app/Core/RedisClient.php b/backend/app/Core/RedisClient.php new file mode 100644 index 0000000..31bc557 --- /dev/null +++ b/backend/app/Core/RedisClient.php @@ -0,0 +1,62 @@ +connect($host, $port, 2.0)) { + throw new \RuntimeException("Could not connect to Redis server at $host:$port"); + } + + // Authenticate if password is provided + if ($password) { + if (!$redis->auth($password)) { + throw new \RuntimeException("Redis authentication failed."); + } + } + + // Set prefix for Saqel to avoid collisions + $redis->setOption(\Redis::OPT_PREFIX, 'saqel:'); + + self::$instance = $redis; + } catch (\Exception $e) { + // In production, fallback gracefully or throw HTTP 500 + error_log("Redis Connection Error: " . $e->getMessage()); + throw new \RuntimeException("Redis is unavailable. Please ensure the Redis server is running."); + } + } + + return self::$instance; + } + + /** + * Helper to close the connection explicitly + */ + public static function close(): void + { + if (self::$instance !== null) { + self::$instance->close(); + self::$instance = null; + } + } +} diff --git a/backend/app/Core/Security.php b/backend/app/Core/Security.php index 0cffaa2..c4c916f 100644 --- a/backend/app/Core/Security.php +++ b/backend/app/Core/Security.php @@ -148,7 +148,7 @@ class Security $payload['iat'] = time(); $payload['exp'] = time() + $expirySeconds; $payload['iss'] = getenv('APP_URL'); // Issuer - $payload['aud'] = 'nabeh_dashboard'; // Audience + $payload['aud'] = 'saqel_app'; // Audience $payload['jti'] = bin2hex(random_bytes(16)); // JWT ID to prevent Replay Attacks $payloadEncoded = self::base64UrlEncode(json_encode($payload)); diff --git a/backend/app/Middlewares/AuthMiddleware.php b/backend/app/Middlewares/AuthMiddleware.php index 6187169..df87803 100644 --- a/backend/app/Middlewares/AuthMiddleware.php +++ b/backend/app/Middlewares/AuthMiddleware.php @@ -28,16 +28,29 @@ class AuthMiddleware exit; } + // Check if session is active in Redis + try { + $redis = \App\Core\RedisClient::getInstance(); + $userId = $payload['user_id']; + $sessionState = $redis->get("session:{$userId}:{$token}"); + + if (!$sessionState || $sessionState !== 'active') { + $response->json(['error' => 'Unauthorized', 'message' => 'Session has been revoked or expired'], 401); + exit; + } + } catch (\Exception $e) { + // If Redis is down, we fallback to just JWT verification + error_log("AuthMiddleware Redis Error: " . $e->getMessage()); + } + // Validate required custom payload elements - if (!isset($payload['user_id']) || !isset($payload['company_id']) || !isset($payload['role'])) { + if (!isset($payload['user_id']) || !isset($payload['role'])) { $response->json(['error' => 'Unauthorized', 'message' => 'Malformed token payload structure'], 401); exit; } // Attach user info to the Request instance dynamically so controllers can use it $request->user_id = $payload['user_id']; - $request->company_id = $payload['company_id']; $request->role = $payload['role']; - $request->is_super_admin = (int)$payload['company_id'] === 1; } } diff --git a/backend/app/Middlewares/RateLimitMiddleware.php b/backend/app/Middlewares/RateLimitMiddleware.php index 521cdb4..c3b964b 100644 --- a/backend/app/Middlewares/RateLimitMiddleware.php +++ b/backend/app/Middlewares/RateLimitMiddleware.php @@ -4,22 +4,16 @@ namespace App\Middlewares; use App\Core\Request; use App\Core\Response; +use App\Core\RedisClient; /** - * Rate Limit Middleware - * Limits the number of requests per IP address using file-based counters. - * Protects sensitive endpoints (login, register) from Brute Force attacks. + * Rate Limit Middleware (Redis Powered) + * Limits the number of requests per IP address using Redis atomic counters. + * Protects sensitive endpoints (login, register, otp) from Brute Force attacks. */ class RateLimitMiddleware { - /** - * Maximum allowed requests within the time window - */ private int $maxAttempts; - - /** - * Time window in seconds - */ private int $decaySeconds; public function __construct(int $maxAttempts = 5, int $decaySeconds = 60) @@ -31,40 +25,36 @@ class RateLimitMiddleware public function handle(Request $request, Response $response): void { $ip = $this->getClientIp(); - $key = 'rate_' . md5($ip . '_' . $request->getPath()); + $key = 'rate_limit:' . md5($ip . '_' . $request->getPath()); - $storageDir = APP_ROOT . '/storage/rate_limits'; - if (!is_dir($storageDir)) { - mkdir($storageDir, 0750, true); - } - - $filePath = $storageDir . '/' . $key . '.json'; - - $data = ['count' => 0, 'expires_at' => time() + $this->decaySeconds]; - - if (file_exists($filePath)) { - $raw = json_decode(file_get_contents($filePath), true); - if ($raw && isset($raw['expires_at']) && $raw['expires_at'] > time()) { - // Window still active — use existing data - $data = $raw; + try { + $redis = RedisClient::getInstance(); + + $current = $redis->get($key); + + if ($current !== false && (int)$current >= $this->maxAttempts) { + $retryAfter = $redis->ttl($key); + $retryAfter = $retryAfter > 0 ? $retryAfter : $this->decaySeconds; + + $response->setHeader('Retry-After', (string)$retryAfter); + $response->json([ + 'error' => 'Too Many Requests', + 'message' => "لقد تجاوزت الحد الأقصى للمحاولات ({$this->maxAttempts}). يرجى المحاولة بعد {$retryAfter} ثانية." + ], 429); + exit; // End request } - // If window expired, fall through and reset (overwrite with fresh data below) + + // Increment atomically + $count = $redis->incr($key); + if ($count === 1) { + // First request, set expiration + $redis->expire($key, $this->decaySeconds); + } + } catch (\Exception $e) { + // If Redis fails, log it but don't block the request completely, + // or we could choose to block it. We'll let it pass to avoid downtime. + error_log("RateLimit Redis Error: " . $e->getMessage()); } - - $data['count']++; - - if ($data['count'] > $this->maxAttempts) { - $retryAfter = max(0, $data['expires_at'] - time()); - $response->setHeader('Retry-After', (string)$retryAfter); - $response->json([ - 'error' => 'Too Many Requests', - 'message' => "You have exceeded the maximum number of {$this->maxAttempts} attempts. Please try again in {$retryAfter} seconds." - ], 429); - return; - } - - // Persist the updated counter - file_put_contents($filePath, json_encode($data), LOCK_EX); } /** diff --git a/backend/database_schema.sql b/backend/database_schema.sql new file mode 100644 index 0000000..9bf4a72 --- /dev/null +++ b/backend/database_schema.sql @@ -0,0 +1,271 @@ +-- ============================================================================== +-- SAQEL PLATFORM (منصة صَقِل) - PRIMARY DATABASE SCHEMA (MySQL 8.4+) +-- Complete Production Database Definition +-- ============================================================================== + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ------------------------------------------------------------------------------ +-- 1. Table: schools (المدارس والجهات الشريكة - B2B مثل الثقافة العسكرية) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `schools`; +CREATE TABLE `schools` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `code` VARCHAR(50) NOT NULL UNIQUE, + `type` ENUM('military_culture', 'private', 'public', 'center') NOT NULL DEFAULT 'private', + `director_name` VARCHAR(255) DEFAULT NULL, + `phone` VARCHAR(50) DEFAULT NULL, + `city` VARCHAR(100) DEFAULT 'Amman', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_schools_uuid` (`uuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 2. Table: users (المستخدمون الموحدون - طلاب، معلمون، أولياء أمور، إدارة) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `users`; +CREATE TABLE `users` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `full_name` TEXT NOT NULL, + `phone_number` TEXT NOT NULL, + `phone_hash` VARCHAR(64) NOT NULL, + `password_hash` VARCHAR(255) NOT NULL, + `role` ENUM('student', 'guardian', 'teacher', 'school_admin', 'super_admin') NOT NULL DEFAULT 'student', + `school_id` BIGINT UNSIGNED DEFAULT NULL, + `grade_level` VARCHAR(50) DEFAULT 'tawjihi_2007', + `stream` ENUM('scientific', 'literary', 'vocational', 'general') DEFAULT 'scientific', + `token_version` INT UNSIGNED NOT NULL DEFAULT 1, + `status` ENUM('active', 'pending_otp', 'suspended') NOT NULL DEFAULT 'pending_otp', + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_users_uuid` (`uuid`), + KEY `idx_users_phone_hash` (`phone_hash`), + KEY `idx_users_school_id` (`school_id`), + CONSTRAINT `fk_users_school` FOREIGN KEY (`school_id`) REFERENCES `schools` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 3. Table: guardian_student (ربط أولياء الأمور بالأبناء - N:M) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `guardian_student`; +CREATE TABLE `guardian_student` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `guardian_id` BIGINT UNSIGNED NOT NULL, + `student_id` BIGINT UNSIGNED NOT NULL, + `relationship_type` ENUM('father', 'mother', 'brother', 'guardian') NOT NULL DEFAULT 'father', + `is_verified` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_guardian_student_unique` (`guardian_id`, `student_id`), + KEY `idx_guardian_student_student` (`student_id`), + CONSTRAINT `fk_gs_guardian` FOREIGN KEY (`guardian_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_gs_student` FOREIGN KEY (`student_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 4. Table: teacher_profiles (ملفات المعلمين ونسب الأرباح) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `teacher_profiles`; +CREATE TABLE `teacher_profiles` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `bio` TEXT DEFAULT NULL, + `specialization` VARCHAR(150) NOT NULL, + `revenue_share_pct` DECIMAL(5,2) NOT NULL DEFAULT 45.00, + `contract_type` ENUM('exclusive', 'non_exclusive') NOT NULL DEFAULT 'exclusive', + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_teacher_user` (`user_id`), + CONSTRAINT `fk_teacher_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 5. Table: subjects (المواد التعليمية) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `subjects`; +CREATE TABLE `subjects` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(150) NOT NULL, + `code` VARCHAR(50) NOT NULL UNIQUE, + `stream` ENUM('scientific', 'literary', 'common') NOT NULL DEFAULT 'common', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 6. Table: courses (الدورات التدريبية) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `courses`; +CREATE TABLE `courses` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `subject_id` BIGINT UNSIGNED NOT NULL, + `teacher_id` BIGINT UNSIGNED NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT DEFAULT NULL, + `semester` ENUM('first', 'second', 'full_year', 'intensive') NOT NULL DEFAULT 'first', + `price_jod` DECIMAL(8,2) NOT NULL DEFAULT 35.00, + `thumbnail_url` VARCHAR(500) DEFAULT NULL, + `is_published` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_courses_uuid` (`uuid`), + KEY `idx_courses_subject` (`subject_id`), + KEY `idx_courses_teacher` (`teacher_id`), + CONSTRAINT `fk_courses_subject` FOREIGN KEY (`subject_id`) REFERENCES `subjects` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_courses_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 7. Table: lessons (الدروس والفيديوهات المربوطة بـ Bunny Stream) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `lessons`; +CREATE TABLE `lessons` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `course_id` BIGINT UNSIGNED NOT NULL, + `title` VARCHAR(255) NOT NULL, + `sequence_order` INT UNSIGNED NOT NULL DEFAULT 1, + `bunny_video_id` VARCHAR(100) NOT NULL, + `duration_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + `is_free_preview` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_lessons_course` (`course_id`), + CONSTRAINT `fk_lessons_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 8. Table: quizzes (الكويزات داخل الفيديو - Socratic Checkpoints) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `quizzes`; +CREATE TABLE `quizzes` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `lesson_id` BIGINT UNSIGNED NOT NULL, + `title` VARCHAR(255) NOT NULL, + `timestamp_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + `is_mandatory` TINYINT(1) NOT NULL DEFAULT 1, + `rewind_on_fail_seconds` INT UNSIGNED NOT NULL DEFAULT 45, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_quizzes_lesson` (`lesson_id`), + CONSTRAINT `fk_quizzes_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 9. Table: questions (الأسئلة) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `questions`; +CREATE TABLE `questions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `quiz_id` BIGINT UNSIGNED NOT NULL, + `question_text` TEXT NOT NULL, + `explanation_text` TEXT DEFAULT NULL, + `points` INT UNSIGNED NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_questions_quiz` (`quiz_id`), + CONSTRAINT `fk_questions_quiz` FOREIGN KEY (`quiz_id`) REFERENCES `quizzes` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 10. Table: question_options (خيارات الإجابة) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `question_options`; +CREATE TABLE `question_options` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `question_id` BIGINT UNSIGNED NOT NULL, + `option_text` TEXT NOT NULL, + `is_correct` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_options_question` (`question_id`), + CONSTRAINT `fk_options_question` FOREIGN KEY (`question_id`) REFERENCES `questions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 11. Table: lesson_progress (سجل متابعة ونبض المشاهدة) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `lesson_progress`; +CREATE TABLE `lesson_progress` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `lesson_id` BIGINT UNSIGNED NOT NULL, + `last_watched_second` INT UNSIGNED NOT NULL DEFAULT 0, + `max_watched_second` INT UNSIGNED NOT NULL DEFAULT 0, + `completion_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `is_completed` TINYINT(1) NOT NULL DEFAULT 0, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_user_lesson_progress` (`user_id`, `lesson_id`), + KEY `idx_progress_lesson` (`lesson_id`), + CONSTRAINT `fk_progress_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_progress_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 12. Table: voice_notes (الملاحظات الصوتية والتفريغ الذكي) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `voice_notes`; +CREATE TABLE `voice_notes` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `user_id` BIGINT UNSIGNED NOT NULL, + `lesson_id` BIGINT UNSIGNED NOT NULL, + `timestamp_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + `audio_url` VARCHAR(500) NOT NULL, + `transcript_text` TEXT DEFAULT NULL, + `ai_summary` TEXT DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_voice_notes_uuid` (`uuid`), + KEY `idx_voice_notes_user_lesson` (`user_id`, `lesson_id`), + CONSTRAINT `fk_vn_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vn_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 13. Table: user_devices (بصمة الأجهزة المربوطة بالجلسات) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `user_devices`; +CREATE TABLE `user_devices` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `device_fingerprint` VARCHAR(64) NOT NULL, + `device_name` VARCHAR(150) DEFAULT NULL, + `platform` ENUM('android', 'ios', 'windows', 'macos', 'web') NOT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `last_active_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_user_device_fingerprint` (`user_id`, `device_fingerprint`), + CONSTRAINT `fk_ud_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 14. Table: otp_verifications (رموز التحقق عبر منصة صقل) +-- ------------------------------------------------------------------------------ +DROP TABLE IF EXISTS `otp_verifications`; +CREATE TABLE `otp_verifications` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `phone_hash` VARCHAR(64) NOT NULL, + `otp_code_hash` VARCHAR(255) NOT NULL, + `attempts` INT UNSIGNED NOT NULL DEFAULT 0, + `is_used` TINYINT(1) NOT NULL DEFAULT 0, + `expires_at` TIMESTAMP NOT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_otp_phone_hash` (`phone_hash`) +) 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 72c5e34..970a692 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -32,9 +32,14 @@ $router->get('/api/health', function ($request, $response) { }); // Authentication Routes (Rate-limited: 5 attempts per 60 seconds per IP) -// $router->post('/api/auth/register', [\App\Controllers\AuthController::class, 'register'], [\App\Middlewares\RateLimitMiddleware::class]); -// $router->post('/api/auth/login', [\App\Controllers\AuthController::class, 'login'], [\App\Middlewares\RateLimitMiddleware::class]); -// $router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/auth/register', [\App\Controllers\AuthController::class, 'register'], [\App\Middlewares\RateLimitMiddleware::class]); +$router->post('/api/auth/login', [\App\Controllers\AuthController::class, 'login'], [\App\Middlewares\RateLimitMiddleware::class]); +$router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]); + +// Teacher Routes +$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]); + // 5. Dispatch the request $router->dispatch($request, $response); diff --git a/backend/public/migrate.php b/backend/public/migrate.php new file mode 100644 index 0000000..198449c --- /dev/null +++ b/backend/public/migrate.php @@ -0,0 +1,17 @@ +getConnection(); + + $sql = file_get_contents(dirname(__DIR__) . '/database_schema.sql'); + + $db->exec($sql); + + echo "Database migrations executed successfully!\n"; +} catch (\Exception $e) { + echo "Error executing migrations: " . $e->getMessage() . "\n"; +}