user_id; $sql = "SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE student_id = ?"; $params = [$studentId]; $subject = trim((string)$request->getQuery('subject', '')); $status = trim((string)$request->getQuery('status', '')); if ($subject !== '') { $sql .= ' AND subject_id = ?'; $params[] = $subject; } if ($status !== '') { $sql .= ' AND status = ?'; $params[] = $status; } $items = Database::select($sql . ' ORDER BY created_at DESC', $params); $mastered = count(array_filter($items, fn($item) => ($item['status'] ?? '') === 'mastered')); $inRemediation = count(array_filter($items, fn($item) => ($item['status'] ?? '') === 'in_remediation')); $pending = count(array_filter($items, fn($item) => ($item['status'] ?? '') === 'pending_remediation')); $bySubject = []; foreach ($items as $item) { $name = (string)($item['subject_name'] ?? ''); if ($name !== '') $bySubject[$name] = ($bySubject[$name] ?? 0) + 1; } $total = count($items); $response->json(['status' => 'success', 'data' => [ 'summary' => [ 'total_errors' => $total, 'mastered_count' => $mastered, 'in_remediation_count' => $inRemediation, 'pending_count' => $pending, 'mastery_percentage' => $total ? round(($mastered / $total) * 100, 1) : 0.0, 'by_subject' => $bySubject, ], 'items' => $items, ]]); } public function logError(Request $request, Response $response): void { $body = $request->getBody(); foreach (['subject_id', 'subject_name', 'topic_name', 'source_type', 'question_text', 'student_wrong_answer', 'correct_answer'] as $field) { if (trim((string)($body[$field] ?? '')) === '') { $response->status(422)->json(['status' => 'error', 'message' => "الحقل {$field} مطلوب"]); return; } } $uuid = $this->uuid(); Database::insert( "INSERT INTO student_error_notebook (uuid, student_id, subject_id, subject_name, topic_name, lesson_id, source_type, question_text, options_json, student_wrong_answer, correct_answer, socratic_hint, error_category, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending_remediation', NOW())", [ $uuid, (int)$request->user_id, $body['subject_id'], $body['subject_name'], $body['topic_name'], !empty($body['lesson_id']) ? (int)$body['lesson_id'] : null, $body['source_type'], $body['question_text'], isset($body['options']) ? json_encode($body['options'], JSON_UNESCAPED_UNICODE) : null, $body['student_wrong_answer'], $body['correct_answer'], (string)($body['socratic_hint'] ?? ''), (string)($body['error_category'] ?? 'conceptual'), ] ); $response->status(201)->json(['status' => 'success', 'uuid' => $uuid]); } public function getRemediationQuiz(Request $request, Response $response): void { $body = $request->getBody(); $uuid = trim((string)($body['error_uuid'] ?? '')); $error = Database::selectOne( "SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1", [$uuid, (int)$request->user_id] ); if (!$error) { $response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة']); return; } if ($error['status'] === 'mastered') { $response->status(409)->json(['status' => 'error', 'message' => 'هذه الفجوة متقنة بالفعل']); return; } if ($error['status'] === 'in_remediation' && (int)$error['retention_is_due'] !== 1) { $response->status(409)->json(['status' => 'not_due', 'message' => 'اختبار الاسترجاع متاح بعد موعده المحدد']); return; } if (empty($error['lesson_id'])) { $response->status(422)->json(['status' => 'unavailable', 'message' => 'لا يوجد درس محدد لهذه الفجوة لتكوين اختبار موثوق']); return; } $dbQuestions = Database::select( "SELECT q.id, q.question_text FROM questions q JOIN exams e ON e.id = q.exam_id WHERE e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ? AND q.question_type = 'multiple_choice' AND q.remediation_review_status = 'approved' AND q.remediation_reviewed_by IS NOT NULL AND q.remediation_reviewed_at IS NOT NULL AND q.remediation_source_reference IS NOT NULL ORDER BY q.id ASC LIMIT 20", [(int)$error['lesson_id'], $error['topic_name']] ); $questions = []; $answerKey = []; foreach ($dbQuestions as $question) { $options = Database::select( 'SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC', [(int)$question['id']] ); if (count($options) < 2 || count(array_filter($options, fn($o) => (int)$o['is_correct'] === 1)) !== 1) continue; $questions[] = [ 'id' => (int)$question['id'], 'question' => $question['question_text'], 'options' => array_map(fn($o) => ['id' => (int)$o['id'], 'text' => $o['option_text']], $options), ]; $answerKey[] = [ 'question_id' => (int)$question['id'], 'option_ids' => array_map(fn($o) => (int)$o['id'], $options), 'correct_option_id' => (int)array_values(array_filter($options, fn($o) => (int)$o['is_correct'] === 1))[0]['id'], ]; if (count($questions) === 3) break; } if (count($questions) !== 3) { $response->status(422)->json(['status' => 'unavailable', 'message' => 'لا توجد ثلاثة أسئلة منشورة ومراجعة لهذا الدرس والمفهوم']); return; } $attemptUuid = $this->uuid(); Database::insert( "INSERT INTO error_notebook_quiz_attempts (uuid,error_id,student_id,phase,question_ids_json,answer_key_json,expires_at) VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 30 MINUTE))", [$attemptUuid, (int)$error['id'], (int)$request->user_id, $error['status'] === 'in_remediation' ? 'retention' : 'initial', json_encode(array_column($questions, 'id')), json_encode($answerKey)] ); $response->json([ 'status' => 'success', 'data' => [ 'error_uuid' => $uuid, 'topic_name' => $error['topic_name'], 'subject_name' => $error['subject_name'], 'attempt_uuid' => $attemptUuid, 'questions' => $questions, ], ]); } public function resolveError(Request $request, Response $response): void { $body = $request->getBody(); $uuid = trim((string)($body['error_uuid'] ?? '')); $attemptUuid = trim((string)($body['attempt_uuid'] ?? '')); $studentId = (int)$request->user_id; $answers = $body['answers'] ?? null; if (!is_array($answers) || count($answers) !== 3) { $response->status(422)->json(['status' => 'error', 'message' => 'يجب إرسال إجابات الأسئلة الثلاثة']); return; } $answersHash = hash('sha256', json_encode($answers)); $pdo = Database::getConnection(); $pdo->beginTransaction(); try { $error = Database::selectOne( 'SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE uuid = ? AND student_id = ? FOR UPDATE', [$uuid, $studentId] ); $attempt = $error ? Database::selectOne( 'SELECT *, (expires_at > NOW()) AS is_fresh FROM error_notebook_quiz_attempts WHERE uuid = ? AND error_id = ? AND student_id = ? FOR UPDATE', [$attemptUuid, (int)$error['id'], $studentId] ) : null; if ($attempt && $attempt['status'] !== 'open' && hash_equals((string)($attempt['answers_sha256'] ?? ''), $answersHash)) { $pdo->commit(); $response->json(['status' => 'success', 'passed' => $attempt['status'] === 'passed', 'correct_count' => (int)$attempt['correct_count'], 'new_status' => $attempt['status'] === 'passed' ? ($attempt['phase'] === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]); return; } if (!$attempt || $attempt['status'] !== 'open' || (int)$attempt['is_fresh'] !== 1) { $pdo->rollBack(); $response->status(409)->json(['status' => 'error', 'message' => 'المحاولة غير متاحة أو انتهت صلاحيتها']); return; } $phase = $error['status'] === 'in_remediation' ? 'retention' : 'initial'; if ($error['status'] === 'mastered' || $attempt['phase'] !== $phase || ($phase === 'retention' && (int)$error['retention_is_due'] !== 1)) { $pdo->rollBack(); $response->status(409)->json(['status' => 'error', 'message' => 'حالة الفجوة تغيرت؛ ابدأ محاولة جديدة']); return; } $ids = json_decode($attempt['question_ids_json'], true); $answerKey = json_decode($attempt['answer_key_json'], true); if (!is_array($ids) || !is_array($answerKey)) { $pdo->rollBack(); $response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']); return; } foreach ($ids as $index => $questionId) { $published = Database::selectOne( "SELECT q.id FROM questions q JOIN exams e ON e.id = q.exam_id WHERE q.id = ? AND e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ? AND q.remediation_review_status = 'approved' AND q.remediation_reviewed_by IS NOT NULL AND q.remediation_reviewed_at IS NOT NULL AND q.remediation_source_reference IS NOT NULL FOR UPDATE", [(int)$questionId, (int)$error['lesson_id'], $error['topic_name']] ); if (!$published) { $pdo->rollBack(); $response->status(409)->json(['status' => 'error', 'message' => 'سُحب سؤال من هذه المحاولة؛ ابدأ محاولة جديدة']); return; } } try { $correct = RemediationAttemptGrader::grade($ids, $answerKey, $answers); } catch (\InvalidArgumentException $invalidAnswer) { $pdo->rollBack(); $response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']); return; } $passed = $correct >= 2; Database::query( "UPDATE error_notebook_quiz_attempts SET status = ?, correct_count = ?, answers_sha256 = ?, submitted_at = NOW() WHERE id = ?", [$passed ? 'passed' : 'failed', $correct, $answersHash, (int)$attempt['id']] ); if ($passed) { Database::query( "UPDATE student_error_notebook SET status = ?, remediation_attempts_count = remediation_attempts_count + 1, retention_due_at = IF(? = 'initial', DATE_ADD(NOW(), INTERVAL 7 DAY), retention_due_at), mastered_at = IF(? = 'retention', NOW(), mastered_at) WHERE id = ?", [$phase === 'retention' ? 'mastered' : 'in_remediation', $phase, $phase, (int)$error['id']] ); } else { Database::query('UPDATE student_error_notebook SET remediation_attempts_count = remediation_attempts_count + 1 WHERE id = ?', [(int)$error['id']]); } $pdo->commit(); $response->json(['status' => 'success', 'passed' => $passed, 'correct_count' => $correct, 'new_status' => $passed ? ($phase === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]); } catch (\Throwable $e) { if ($pdo->inTransaction()) $pdo->rollBack(); throw $e; } } private function uuid(): string { $data = random_bytes(16); $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); } }