256 lines
13 KiB
PHP
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));
|
|
}
|
|
}
|