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.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}
+
+ `;
+
+ 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,