feat(geom9 ch3 wave2 + final): §12 «Герон» + Финал Главы 3
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
/* ── Statements (prepared once) ────────────────────────────────── */
|
||||
const SQL = {
|
||||
listTracks: db.prepare(`
|
||||
SELECT exam_key, title, subject_slug, grade, duration_min,
|
||||
tasks_per_variant, variants_count, intro_html, sort_order
|
||||
FROM exam_tracks
|
||||
WHERE enabled = 1
|
||||
ORDER BY sort_order, exam_key
|
||||
`),
|
||||
getTrack: db.prepare(`
|
||||
SELECT exam_key, title, subject_slug, grade, duration_min,
|
||||
tasks_per_variant, variants_count, scoring_json, intro_html
|
||||
FROM exam_tracks
|
||||
WHERE exam_key = ? AND enabled = 1
|
||||
`),
|
||||
countTasks: db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN task_type = 'mc' THEN 1 ELSE 0 END), 0) AS mc,
|
||||
COALESCE(SUM(CASE WHEN task_type = 'open' THEN 1 ELSE 0 END), 0) AS open,
|
||||
COALESCE(SUM(CASE WHEN task_type = 'long' THEN 1 ELSE 0 END), 0) AS long
|
||||
FROM exam_tasks
|
||||
WHERE exam_key = ?
|
||||
`),
|
||||
/* For each (user, task) — was the most-recent attempt correct?
|
||||
A task counts as «solved» iff at least one historical attempt is_correct = 1. */
|
||||
userProgress: db.prepare(`
|
||||
SELECT
|
||||
COUNT(DISTINCT exam_task_id) AS tasks_attempted,
|
||||
COUNT(DISTINCT CASE WHEN is_correct = 1 THEN exam_task_id END) AS tasks_solved,
|
||||
COUNT(*) AS total_attempts,
|
||||
COALESCE(SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct_attempts
|
||||
FROM exam_attempts a
|
||||
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||||
WHERE a.user_id = ? AND t.exam_key = ?
|
||||
`),
|
||||
};
|
||||
|
||||
/* ── GET /api/exam-prep/tracks ──
|
||||
Public list of enabled exam tracks (for a future landing page). */
|
||||
router.get('/tracks', (_req, res) => {
|
||||
const tracks = SQL.listTracks.all();
|
||||
res.json({ tracks });
|
||||
});
|
||||
|
||||
/* ── GET /api/exam-prep/:examKey/info ──
|
||||
Track metadata + global counts + this user's aggregate progress. */
|
||||
router.get('/:examKey/info', (req, res) => {
|
||||
const { examKey } = req.params;
|
||||
const track = SQL.getTrack.get(examKey);
|
||||
if (!track) return res.status(404).json({ error: 'Unknown exam track' });
|
||||
|
||||
const counts = SQL.countTasks.get(examKey);
|
||||
const progress = SQL.userProgress.get(req.user.id, examKey);
|
||||
|
||||
// Parse scoring grid (JSON in DB). Tolerant of malformed values.
|
||||
let scoring = null;
|
||||
if (track.scoring_json) {
|
||||
try { scoring = JSON.parse(track.scoring_json); } catch { scoring = null; }
|
||||
}
|
||||
|
||||
res.json({
|
||||
track: {
|
||||
exam_key: track.exam_key,
|
||||
title: track.title,
|
||||
subject: track.subject_slug,
|
||||
grade: track.grade,
|
||||
duration_min: track.duration_min,
|
||||
tasks_per_variant: track.tasks_per_variant,
|
||||
variants_count: track.variants_count,
|
||||
intro_html: track.intro_html,
|
||||
scoring,
|
||||
},
|
||||
counts,
|
||||
progress,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -51,6 +51,7 @@ const collectionRoutes = require('./routes/collection');
|
||||
const redBookRoutes = require('./routes/red-book');
|
||||
const parentRoutes = require('./routes/parent');
|
||||
const exam9Routes = require('./routes/exam9');
|
||||
const examPrepRoutes = require('./routes/exam-prep');
|
||||
const textbookRoutes = require('./routes/textbooks');
|
||||
const teacherStudentsRoutes = require('./routes/teacherStudents');
|
||||
|
||||
@@ -171,6 +172,7 @@ app.use('/api/red-book', requireFeature('red_book'), redBookRoutes);
|
||||
app.use('/api/biochem', requireFeature('biochem'), require('./routes/biochem'));
|
||||
app.use('/api/parent', parentRoutes);
|
||||
app.use('/api/exam9', exam9Routes);
|
||||
app.use('/api/exam-prep', examPrepRoutes);
|
||||
app.use('/api/textbooks', textbookRoutes);
|
||||
app.use('/api/teacher-students', teacherStudentsRoutes);
|
||||
|
||||
@@ -340,6 +342,34 @@ app.get('/textbooks', (_req, res) => {
|
||||
res.sendFile(path.join(frontendDir, 'textbooks.html'));
|
||||
});
|
||||
|
||||
// ── Exam preparation module: /exam-prep/:examKey/{,variants,practice,topics,mock}[/...]
|
||||
// All sub-pages share the same examKey segment; sub-view is the second segment.
|
||||
// The HTML file is chosen by the sub-view; examKey is parsed client-side from URL.
|
||||
function sendExamPrep(res, file) {
|
||||
if (!isProd) res.setHeader('Cache-Control', 'no-store');
|
||||
res.sendFile(path.join(frontendDir, file));
|
||||
}
|
||||
const EXAM_PREP_VIEWS = {
|
||||
variants: 'exam-prep-variants.html',
|
||||
practice: 'exam-prep-practice.html',
|
||||
topics: 'exam-prep-topics.html',
|
||||
mock: 'exam-prep-mock.html',
|
||||
};
|
||||
// /exam-prep → redirect to default track (math9 for now)
|
||||
app.get('/exam-prep', (_req, res) => res.redirect(302, '/exam-prep/math9'));
|
||||
// /exam-prep/:examKey → dashboard
|
||||
app.get('/exam-prep/:examKey', (_req, res) => sendExamPrep(res, 'exam-prep.html'));
|
||||
// /exam-prep/:examKey/:view → corresponding sub-page (sub-view + optional trailing segments)
|
||||
app.get(['/exam-prep/:examKey/:view', '/exam-prep/:examKey/:view/*'], (req, res, next) => {
|
||||
const file = EXAM_PREP_VIEWS[req.params.view];
|
||||
if (!file) return next();
|
||||
sendExamPrep(res, file);
|
||||
});
|
||||
|
||||
// NOTE: /exam9 remains live (served by static middleware as exam9.html) until F2
|
||||
// ports the variant browser into /exam-prep/:examKey/variants. The 301 redirect
|
||||
// will be added at that point.
|
||||
|
||||
// Serve HTML files without extension (/dashboard → dashboard.html)
|
||||
// In dev: disable cache so edits are always picked up immediately
|
||||
const htmlCacheOpts = isProd ? { extensions: ['html'] } : {
|
||||
|
||||
Reference in New Issue
Block a user