From deda7c3c1893bfb361519aac4dc745a8f891bbab Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Sat, 11 Jul 2026 11:24:37 -0400 Subject: [PATCH] Add quiz support: parse *_quiz_solved.txt files, take and grade quizzes Scanner indexes quiz files found next to videos ([&] questions, "- " options, "-- Correct" markers; _notsolved twins ignored). Quizzes appear in the folder content list interleaved with videos by filename order. New api/quiz.php serves questions without answers and grades attempts server-side; per-user best/last score and attempts stored in quiz_progress. Folder/course progress now aggregates videos + quizzes with partial credit for partial scores. Co-Authored-By: Claude Fable 5 --- README.md | 24 +++++++ api/library.php | 16 +++++ api/quiz.php | 122 ++++++++++++++++++++++++++++++++ assets/app.js | 139 +++++++++++++++++++++++++++++++++---- includes/functions.php | 154 +++++++++++++++++++++++++++++++++++++---- scan.php | 5 +- schema.sql | 35 ++++++++++ 7 files changed, 467 insertions(+), 28 deletions(-) create mode 100644 api/quiz.php diff --git a/README.md b/README.md index 645d647..5a59d00 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,30 @@ Top-level folders become courses on the home page. 5. Open the site, create an account, and watch. +## Quizzes + +Course folders may contain quiz text files next to the videos. Files named +`*_quiz_solved.txt` are indexed (a `*_quiz_notsolved.txt` twin is ignored — +it's the same content without answers). Format: + +``` +[&] Which two are supported CLI connectivity methods? (Select two) +- RDP +- rlogin +- GUI widget -- Correct +- FortiExplorer -- Correct +``` + +Questions start with `[&]`, options with `- `, and correct options end with +`-- Correct`. Questions with more than one correct option automatically render +as checkboxes instead of radios. + +Quizzes are graded server-side (answers are never sent to the browser before +submission). A question counts only if the selected set exactly matches the +correct set. Best score, last score, and attempt count are stored per user; +a quiz is *completed* once every question is answered correctly in one +attempt, and quiz scores count toward folder progress alongside videos. + ## How progress works - The player POSTs `{video_id, position, duration}` to `api/progress.php` diff --git a/api/library.php b/api/library.php index bf890c2..0910f87 100644 --- a/api/library.php +++ b/api/library.php @@ -24,6 +24,7 @@ function videoSummary(array $v): array return [ 'id' => (int)$v['id'], 'name' => $v['name'], + 'filename' => $v['filename'], 'duration' => $duration !== null ? (float)$duration : null, 'position' => $position, 'completed' => (bool)($v['completed'] ?? false), @@ -31,6 +32,19 @@ function videoSummary(array $v): array ]; } +function quizSummary(array $q): array +{ + return [ + 'id' => (int)$q['id'], + 'name' => $q['name'], + 'filename' => $q['filename'], + 'questions' => (int)$q['num_questions'], + 'attempts' => (int)($q['attempts'] ?? 0), + 'best_score' => (int)($q['best_score'] ?? 0), + 'completed' => (bool)($q['completed'] ?? false), + ]; +} + function crumbList(array $folder): array { return array_map( @@ -70,6 +84,8 @@ switch ($view) { childFolders((int)$folder['id'])), 'videos' => array_map('videoSummary', folderVideos((int)$folder['id'], $userId)), + 'quizzes' => array_map('quizSummary', + folderQuizzes((int)$folder['id'], $userId)), ]); break; diff --git a/api/quiz.php b/api/quiz.php new file mode 100644 index 0000000..a1ae116 --- /dev/null +++ b/api/quiz.php @@ -0,0 +1,122 @@ + quiz questions + options (no answers) + user's results +// POST api/quiz.php -> grade an attempt, save the result +// JSON body: {quiz_id, answers: [[optIdx,...], ...]} (one array per question) +// Response includes the correct options so the UI can mark the attempt. +require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/functions.php'; + +header('Content-Type: application/json'); +$userId = requireLoginApi(); + +function quizProgressRow(int $userId, int $quizId): array +{ + $stmt = db()->prepare( + 'SELECT best_score, last_score, total_questions, attempts, completed + FROM quiz_progress WHERE user_id = ? AND quiz_id = ?' + ); + $stmt->execute([$userId, $quizId]); + return $stmt->fetch() + ?: ['best_score' => 0, 'last_score' => 0, 'total_questions' => 0, + 'attempts' => 0, 'completed' => 0]; +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $quiz = getQuiz((int)($_GET['id'] ?? 0)); + if (!$quiz) { + http_response_code(404); + echo json_encode(['error' => 'Quiz not found']); + exit; + } + $file = quizFilePath($quiz); + $questions = $file ? parseQuizFile($file) : []; + if (!$questions) { + http_response_code(404); + echo json_encode(['error' => 'Quiz file is missing — rescan the library']); + exit; + } + $folder = getFolder((int)$quiz['folder_id']); + $p = quizProgressRow($userId, (int)$quiz['id']); + echo json_encode([ + 'quiz' => ['id' => (int)$quiz['id'], 'name' => $quiz['name']], + 'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']], + 'breadcrumbs' => array_map( + fn($c) => ['id' => (int)$c['id'], 'name' => $c['name']], + folderBreadcrumbs($folder) + ), + 'questions' => array_map(fn($q) => [ + 'text' => $q['text'], + 'multi' => $q['multi'], + 'options' => $q['options'], + ], $questions), + 'progress' => [ + 'attempts' => (int)$p['attempts'], + 'best_score' => (int)$p['best_score'], + 'last_score' => (int)$p['last_score'], + 'total' => count($questions), + 'completed' => (bool)$p['completed'], + ], + ]); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed']); + exit; +} + +$data = json_decode(file_get_contents('php://input'), true) ?: []; +$quiz = getQuiz((int)($data['quiz_id'] ?? 0)); +$answers = $data['answers'] ?? null; +if (!$quiz || !is_array($answers)) { + http_response_code(400); + echo json_encode(['error' => 'Bad request']); + exit; +} + +$file = quizFilePath($quiz); +$questions = $file ? parseQuizFile($file) : []; +if (!$questions) { + http_response_code(404); + echo json_encode(['error' => 'Quiz file is missing — rescan the library']); + exit; +} + +$score = 0; +$results = []; +foreach ($questions as $i => $q) { + $selected = array_values(array_unique(array_map('intval', (array)($answers[$i] ?? [])))); + sort($selected); + $correct = $q['correct']; + sort($correct); + $isCorrect = $selected === $correct; + if ($isCorrect) $score++; + $results[] = [ + 'correct' => $isCorrect, + 'correct_options' => $correct, + 'selected' => $selected, + ]; +} + +$total = count($questions); +$completed = ($score === $total) ? 1 : 0; + +db()->prepare( + 'INSERT INTO quiz_progress + (user_id, quiz_id, best_score, last_score, total_questions, attempts, completed) + VALUES (?, ?, ?, ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE + last_score = VALUES(last_score), + best_score = GREATEST(best_score, VALUES(best_score)), + total_questions = VALUES(total_questions), + attempts = attempts + 1, + completed = GREATEST(completed, VALUES(completed))' +)->execute([$userId, (int)$quiz['id'], $score, $score, $total, $completed]); + +echo json_encode([ + 'score' => $score, + 'total' => $total, + 'completed' => (bool)$completed, + 'results' => $results, +]); diff --git a/assets/app.js b/assets/app.js index d4da139..0d2d0e5 100644 --- a/assets/app.js +++ b/assets/app.js @@ -173,7 +173,7 @@ ${progressBar(c.progress.percent)}
- ${c.progress.completed} / ${c.progress.total} videos completed · ${c.progress.percent}% + ${c.progress.completed} / ${c.progress.total} completed · ${c.progress.percent}%
@@ -206,23 +206,44 @@ `).join('')} ` : ''; - const videos = data.videos.length ? ` -

Videos

+ const items = [ + ...data.videos.map((v) => ({ ...v, kind: 'video' })), + ...data.quizzes.map((q) => ({ ...q, kind: 'quiz' })), + ].sort((a, b) => a.filename.localeCompare(b.filename, undefined, + { numeric: true, sensitivity: 'base' })); + + const content = items.length ? ` +

Content

- ${data.videos.map((v) => { - const icon = v.completed + ${items.map((it) => { + if (it.kind === 'quiz') { + const icon = it.completed + ? '' + : ''; + return ` + + ${icon} +
+
${esc(it.name)}
+
+ ${it.attempts ? ` + ${it.best_score}/${it.questions}` : ''} + Quiz · ${it.questions} questions +
`; + } + const icon = it.completed ? '' - : (v.position > 5 + : (it.position > 5 ? '' : ''); return ` - + ${icon}
-
${esc(v.name)}
- ${!v.completed && v.percent > 0 ? `
${progressBar(v.percent, '')}
` : ''} +
${esc(it.name)}
+ ${!it.completed && it.percent > 0 ? `
${progressBar(it.percent, '')}
` : ''}
- ${fmt(v.duration)} + ${fmt(it.duration)}
`; }).join('')}
` : (!data.subfolders.length ? '
This folder is empty.
' : ''); @@ -231,10 +252,100 @@ ${breadcrumbs(data.breadcrumbs)}

${esc(data.folder.name)}

-
${data.progress.completed} / ${data.progress.total} videos completed
+
${data.progress.completed} / ${data.progress.total} completed
${progressBar(data.progress.percent)}
- ${subfolders}${videos}`; + ${subfolders}${content}`; + } + + async function renderQuiz(id) { + spinner(); + const data = await api(`api/quiz.php?id=${id}`); + + const qHtml = data.questions.map((q, qi) => ` +
+
+
+ ${qi + 1}. ${esc(q.text)} + ${q.multi ? 'multiple answers' : ''} +
+ ${q.options.map((o, oi) => ` +
+ + + +
`).join('')} +
+
+
`).join(''); + + const best = data.progress.attempts + ? `Best score: ${data.progress.best_score}/${data.progress.total}` + + (data.progress.completed ? ' ' : '') + : 'Not attempted yet'; + + app.innerHTML = ` + ${breadcrumbs(data.breadcrumbs, data.quiz.name)} +
+

${esc(data.quiz.name)}

+
${best}
+
+
+ ${qHtml} +
+ + + Back to ${esc(data.folder.name)} + + +
+
`; + + const form = document.getElementById('quiz-form'); + let graded = false; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (graded) { renderQuiz(id); return; } // "Try again" re-renders fresh + + const answers = data.questions.map((q, qi) => + [...form.querySelectorAll(`input[name="q${qi}"]:checked`)].map((i) => +i.value)); + + const res = await api('api/quiz.php', { json: { quiz_id: id, answers } }); + graded = true; + + res.results.forEach((r, qi) => { + const card = form.querySelector(`[data-qi="${qi}"]`); + r.correct_options.forEach((oi) => { + const row = card.querySelector(`[data-oi="${oi}"]`); + row.querySelector('label').classList.add('text-success', 'fw-semibold'); + row.querySelector('.quiz-mark').innerHTML = ''; + }); + r.selected.filter((oi) => !r.correct_options.includes(oi)).forEach((oi) => { + const row = card.querySelector(`[data-oi="${oi}"]`); + row.querySelector('label').classList.add('text-danger'); + row.querySelector('.quiz-mark').innerHTML = ''; + }); + const fb = card.querySelector('.quiz-feedback'); + fb.innerHTML = r.correct + ? 'Correct' + : 'Incorrect'; + card.classList.add('border', r.correct ? 'border-success' : 'border-danger'); + }); + + form.querySelectorAll('input').forEach((i) => { i.disabled = true; }); + document.getElementById('quiz-score').textContent = `Score: ${res.score}/${res.total}`; + const btn = document.getElementById('quiz-submit'); + btn.innerHTML = 'Try again'; + btn.classList.replace('btn-primary', 'btn-outline-primary'); + toast(res.completed + ? `Perfect score — ${res.score}/${res.total}!` + : `Scored ${res.score}/${res.total}.`, + res.completed ? 'success' : 'secondary'); + }); } async function renderWatch(id) { @@ -374,6 +485,7 @@ try { if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]); else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]); + else if ((m = hash.match(/^#\/quiz\/(\d+)$/))) await renderQuiz(+m[1]); else await renderHome(); } catch (err) { if (state.user) { @@ -399,7 +511,8 @@ btn.innerHTML = 'Scanning…'; try { const res = await api('api/scan.php', { method: 'POST' }); - toast(`Scan complete: ${res.stats.folders} folders, ${res.stats.videos} videos indexed.`); + toast(`Scan complete: ${res.stats.folders} folders, ${res.stats.videos} videos, ` + + `${res.stats.quizzes} quizzes indexed.`); route(); // refresh current view with new data } catch (err) { toast(err.message, 'danger'); diff --git a/includes/functions.php b/includes/functions.php index 86de09f..4c8d60d 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -6,11 +6,53 @@ function h(?string $s): string return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8'); } +/* --------------------------------------------------------------------------- + * Quiz file parser + * Format ("*_quiz_solved.txt"): + * [&] Question text (Select two) + * - wrong option + * - right option -- Correct + * Returns [['text', 'options' => [...], 'correct' => [optIndex,...], 'multi'], ...] + * ------------------------------------------------------------------------- */ +function parseQuizFile(string $absPath): array +{ + $raw = @file_get_contents($absPath); + if ($raw === false) return []; + + $questions = []; + $cur = null; + foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) { + $line = trim($line); + if ($line === '') continue; + + if (str_starts_with($line, '[&]')) { + if ($cur && $cur['options']) $questions[] = $cur; + $cur = ['text' => trim(substr($line, 3)), 'options' => [], 'correct' => []]; + } elseif (str_starts_with($line, '-') && $cur !== null) { + $opt = trim(substr($line, 1)); + if (preg_match('/\s*--\s*correct\s*$/i', $opt)) { + $opt = trim(preg_replace('/\s*--\s*correct\s*$/i', '', $opt)); + $cur['correct'][] = count($cur['options']); + } + $cur['options'][] = $opt; + } elseif ($cur !== null && !$cur['options']) { + $cur['text'] .= ' ' . $line; // wrapped question text + } + } + if ($cur && $cur['options']) $questions[] = $cur; + + foreach ($questions as &$q) { + $q['multi'] = count($q['correct']) > 1; + } + return $questions; +} + /* --------------------------------------------------------------------------- * Library scanner - * Walks VIDEOS_ROOT recursively, upserts every directory into `folders` and - * every *.mp4 into `videos` (keyed by relative path so IDs stay stable across - * rescans), then flags rows whose files disappeared as is_missing. + * Walks VIDEOS_ROOT recursively, upserts every directory into `folders`, + * every *.mp4 into `videos`, and every *_quiz_solved.txt into `quizzes` + * (keyed by relative path so IDs stay stable across rescans), then flags + * rows whose files disappeared as is_missing. * ------------------------------------------------------------------------- */ function scanLibrary(): array { @@ -20,9 +62,11 @@ function scanLibrary(): array } $db = db(); - $stats = ['folders' => 0, 'videos' => 0, 'missing_folders' => 0, 'missing_videos' => 0]; + $stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0, + 'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0]; $seenFolderIds = []; $seenVideoIds = []; + $seenQuizIds = []; $upsertFolder = $db->prepare( 'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing) @@ -39,9 +83,18 @@ function scanLibrary(): array sort_order = VALUES(sort_order), is_missing = 0, id = LAST_INSERT_ID(id)' ); + $upsertQuiz = $db->prepare( + 'INSERT INTO quizzes (folder_id, name, filename, path, num_questions, sort_order, is_missing) + VALUES (?, ?, ?, ?, ?, ?, 0) + ON DUPLICATE KEY UPDATE folder_id = VALUES(folder_id), name = VALUES(name), + filename = VALUES(filename), num_questions = VALUES(num_questions), + sort_order = VALUES(sort_order), is_missing = 0, + id = LAST_INSERT_ID(id)' + ); $walk = function (string $dir, ?int $parentId, string $relBase, int $depth) - use (&$walk, $db, $upsertFolder, $upsertVideo, &$stats, &$seenFolderIds, &$seenVideoIds): void { + use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz, + &$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void { $entries = scandir($dir) ?: []; $entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.')); @@ -49,6 +102,7 @@ function scanLibrary(): array $folderSort = 0; $videoSort = 0; + $quizSort = 0; foreach ($entries as $entry) { $full = $dir . '/' . $entry; $rel = $relBase === '' ? $entry : $relBase . '/' . $entry; @@ -67,6 +121,18 @@ function scanLibrary(): array $upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]); $seenVideoIds[] = (int)$db->lastInsertId(); $stats['videos']++; + } elseif (is_file($full) && preg_match('/_quiz_solved\.txt$/i', $entry)) { + if ($parentId === null) { + continue; + } + $numQuestions = count(parseQuizFile($full)); + if ($numQuestions === 0) { + continue; // unparseable file — don't index an empty quiz + } + $name = trim(preg_replace('/_quiz_solved\.txt$/i', '', $entry)); + $upsertQuiz->execute([$parentId, $name, $entry, $rel, $numQuestions, $quizSort++]); + $seenQuizIds[] = (int)$db->lastInsertId(); + $stats['quizzes']++; } } }; @@ -87,6 +153,7 @@ function scanLibrary(): array }; $stats['missing_folders'] = $flagMissing('folders', $seenFolderIds); $stats['missing_videos'] = $flagMissing('videos', $seenVideoIds); + $stats['missing_quizzes'] = $flagMissing('quizzes', $seenQuizIds); $db->commit(); } catch (Throwable $e) { @@ -101,12 +168,14 @@ function scanLibrary(): array * ------------------------------------------------------------------------- */ /** - * Progress for one folder's whole subtree, matched by path prefix. - * Returns ['total' => n, 'completed' => n, 'percent' => 0-100]. - * Percent gives partial credit for videos that are started but not finished. + * Progress for one folder's whole subtree (videos + quizzes), matched by + * path prefix. Returns ['total' => n, 'completed' => n, 'percent' => 0-100]. + * Percent gives partial credit for half-watched videos and partial scores. */ function folderProgress(int $userId, string $folderPath): array { + $args = [$userId, $folderPath, likePrefix($folderPath) . '/%']; + $stmt = db()->prepare( "SELECT COUNT(*) AS total, COALESCE(SUM(vp.completed), 0) AS completed, @@ -124,13 +193,35 @@ function folderProgress(int $userId, string $folderPath): array WHERE v.is_missing = 0 AND f.is_missing = 0 AND (f.path = ? OR f.path LIKE ?)" ); - $stmt->execute([$userId, $folderPath, likePrefix($folderPath) . '/%']); - $row = $stmt->fetch(); - $total = (int)$row['total']; + $stmt->execute($args); + $videos = $stmt->fetch(); + + $stmt = db()->prepare( + "SELECT COUNT(*) AS total, + COALESCE(SUM(qp.completed), 0) AS completed, + COALESCE(SUM( + CASE + WHEN qp.completed = 1 THEN 1 + WHEN q.num_questions > 0 + THEN LEAST(COALESCE(qp.best_score, 0) / q.num_questions, 1) + ELSE 0 + END + ), 0) AS credit + FROM quizzes q + JOIN folders f ON f.id = q.folder_id + LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ? + WHERE q.is_missing = 0 AND f.is_missing = 0 + AND (f.path = ? OR f.path LIKE ?)" + ); + $stmt->execute($args); + $quizzes = $stmt->fetch(); + + $total = (int)$videos['total'] + (int)$quizzes['total']; + $credit = $videos['credit'] + $quizzes['credit']; return [ 'total' => $total, - 'completed' => (int)$row['completed'], - 'percent' => $total > 0 ? (int)round(100 * $row['credit'] / $total) : 0, + 'completed' => (int)$videos['completed'] + (int)$quizzes['completed'], + 'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0, ]; } @@ -182,6 +273,43 @@ function folderVideos(int $folderId, int $userId): array return $stmt->fetchAll(); } +function getQuiz(int $id): ?array +{ + $stmt = db()->prepare( + 'SELECT q.*, f.path AS folder_path, f.name AS folder_name + FROM quizzes q JOIN folders f ON f.id = q.folder_id + WHERE q.id = ? AND q.is_missing = 0' + ); + $stmt->execute([$id]); + return $stmt->fetch() ?: null; +} + +/** Quizzes in a folder, joined with the user's results. */ +function folderQuizzes(int $folderId, int $userId): array +{ + $stmt = db()->prepare( + 'SELECT q.*, qp.best_score, qp.last_score, qp.attempts, qp.completed + FROM quizzes q + LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ? + WHERE q.folder_id = ? AND q.is_missing = 0 + ORDER BY q.sort_order, q.name' + ); + $stmt->execute([$userId, $folderId]); + return $stmt->fetchAll(); +} + +/** Absolute, containment-checked path of a quiz file (null if gone). */ +function quizFilePath(array $quiz): ?string +{ + $file = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $quiz['path']); + $root = realpath(VIDEOS_ROOT); + if ($file === false || $root === false + || !str_starts_with($file, $root . DIRECTORY_SEPARATOR) || !is_file($file)) { + return null; + } + return $file; +} + /** Breadcrumb chain root -> ... -> folder. */ function folderBreadcrumbs(array $folder): array { diff --git a/scan.php b/scan.php index b652d35..6f88db7 100644 --- a/scan.php +++ b/scan.php @@ -9,5 +9,6 @@ if (PHP_SAPI !== 'cli') { } $stats = scanLibrary(); -echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos" - . " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos flagged missing)\n"; +echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos, {$stats['quizzes']} quizzes" + . " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos" + . " / {$stats['missing_quizzes']} quizzes flagged missing)\n"; diff --git a/schema.sql b/schema.sql index 99ab945..9781f6a 100644 --- a/schema.sql +++ b/schema.sql @@ -41,6 +41,41 @@ CREATE TABLE IF NOT EXISTS videos ( INDEX idx_videos_folder (folder_id) ) ENGINE=InnoDB; +-- One row per "*_quiz_solved.txt" file found next to the videos. +-- Questions/options are parsed from the file on demand; only the count is +-- cached here for progress math. +CREATE TABLE IF NOT EXISTS quizzes ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + folder_id INT UNSIGNED NOT NULL, + name VARCHAR(255) NOT NULL, -- filename without the _quiz_solved.txt suffix + filename VARCHAR(255) NOT NULL, + path VARCHAR(700) NOT NULL UNIQUE, + num_questions INT UNSIGNED NOT NULL DEFAULT 0, + sort_order INT UNSIGNED NOT NULL DEFAULT 0, + is_missing TINYINT(1) NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_quizzes_folder FOREIGN KEY (folder_id) + REFERENCES folders(id) ON DELETE CASCADE, + INDEX idx_quizzes_folder (folder_id) +) ENGINE=InnoDB; + +-- Per-user quiz results. completed = every question answered correctly +-- in a single attempt (sticky). +CREATE TABLE IF NOT EXISTS quiz_progress ( + user_id INT UNSIGNED NOT NULL, + quiz_id INT UNSIGNED NOT NULL, + best_score INT UNSIGNED NOT NULL DEFAULT 0, + last_score INT UNSIGNED NOT NULL DEFAULT 0, + total_questions INT UNSIGNED NOT NULL DEFAULT 0, + attempts INT UNSIGNED NOT NULL DEFAULT 0, + completed TINYINT(1) NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, quiz_id), + CONSTRAINT fk_qp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_qp_quiz FOREIGN KEY (quiz_id) REFERENCES quizzes(id) ON DELETE CASCADE +) ENGINE=InnoDB; + -- Per-user playback state: resume position + completion flag. CREATE TABLE IF NOT EXISTS video_progress ( user_id INT UNSIGNED NOT NULL,