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

256 lines
13 KiB
PHP

<?php
namespace App\Controllers;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
use App\Services\RemediationAttemptGrader;
class ErrorNotebookController
{
public function getErrorNotebook(Request $request, Response $response): void
{
$studentId = (int)$request->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));
}
}