feat(catalog): хаб-страница для Алгебры 8 (3 главы под единым слагом)
- migration 014: parent_slug column + algebra-8 hub row + rename old algebra-8 → algebra-8-ch1 (progress сохраняется через стабильный textbook_id=3) - backend/routes/textbooks.js: GET / фильтрует parent_slug IS NULL; aggregated progress для хабов; новый GET /:slug/children - algebra_8_hub.html: новая хаб-страница с 3 карточками глав, hero с общим прогрессом, XP-бейдж, ссылки на главы - algebra_8/ch2/ch3: кнопки cross-chapter заменены на одну «К алгебре 8» в шапке Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
-- Algebra 8 hub migration.
|
||||
-- Adds parent_slug column so chapters can be grouped under a hub textbook.
|
||||
-- The original algebra-8 row (id=3) becomes algebra-8-ch1; its id never changes
|
||||
-- so all textbook_progress rows keep their FK references intact.
|
||||
|
||||
-- 1. Add nullable parent_slug column (idempotent: will fail gracefully if already exists,
|
||||
-- but migrations-runner wraps each file in a transaction so this is fine as a fresh run).
|
||||
ALTER TABLE textbooks ADD COLUMN parent_slug TEXT;
|
||||
|
||||
-- 2. Rename the existing chapter-1 slug from 'algebra-8' to 'algebra-8-ch1'.
|
||||
-- Row id=3 is untouched, so textbook_progress.textbook_id=3 continues to resolve correctly.
|
||||
UPDATE textbooks SET slug = 'algebra-8-ch1' WHERE slug = 'algebra-8';
|
||||
|
||||
-- 3. Insert the new hub row.
|
||||
INSERT INTO textbooks
|
||||
(slug, subject, grade, title, author, description, html_path, para_count, color, sort_order, is_active)
|
||||
VALUES
|
||||
('algebra-8', 'math', 8, 'Алгебра — 8 класс', '',
|
||||
'Полный курс алгебры 8 класса: квадратные корни и действительные числа, квадратные уравнения, неравенства с одной переменной. 3 главы, 21 параграф + 3 финала, 100+ интерактивов, 21 босс-проверка.',
|
||||
'algebra_8_hub.html', 21, 'pink', 3, 1);
|
||||
|
||||
-- 4. Tag all three chapter rows as children of the hub.
|
||||
UPDATE textbooks SET parent_slug = 'algebra-8' WHERE slug IN ('algebra-8-ch1', 'algebra-8-ch2', 'algebra-8-ch3');
|
||||
@@ -59,24 +59,66 @@ function checkAssignmentCompletion(userId, textbookId, readSet) {
|
||||
LITERAL ROUTES FIRST — must come before /:slug
|
||||
════════════════════════════════════════════════ */
|
||||
|
||||
/* GET /api/textbooks — list with current user's progress */
|
||||
/* GET /api/textbooks — catalog list (top-level only) with current user's progress.
|
||||
Rows with a parent_slug are chapters hidden from the catalog but still directly accessible. */
|
||||
router.get('/', (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT t.id, t.slug, t.subject, t.grade, t.title, t.author, t.description,
|
||||
t.html_path, t.para_count, t.color, t.sort_order
|
||||
FROM textbooks t WHERE t.is_active = 1
|
||||
FROM textbooks t WHERE t.is_active = 1 AND t.parent_slug IS NULL
|
||||
ORDER BY t.sort_order, t.subject, t.grade
|
||||
`).all();
|
||||
|
||||
const myProgress = db.prepare(`
|
||||
SELECT textbook_id, paragraphs_read, last_para, last_at FROM textbook_progress WHERE user_id=?
|
||||
`).all(req.user.id);
|
||||
const map = {};
|
||||
const progressById = {};
|
||||
for (const p of myProgress) {
|
||||
let arr = [];
|
||||
try { arr = JSON.parse(p.paragraphs_read || '[]'); } catch {}
|
||||
map[p.textbook_id] = { read: arr, last_para: p.last_para, last_at: p.last_at };
|
||||
progressById[p.textbook_id] = { read: arr, last_para: p.last_para, last_at: p.last_at };
|
||||
}
|
||||
res.json({ textbooks: rows.map(t => ({ ...t, progress: map[t.id] || { read: [], last_para: null, last_at: null } })) });
|
||||
|
||||
/* Prepared statements reused for each hub row that has children. */
|
||||
const stmtChildren = db.prepare(`
|
||||
SELECT id FROM textbooks WHERE parent_slug = ? AND is_active = 1
|
||||
`);
|
||||
const stmtChildProgress = db.prepare(`
|
||||
SELECT textbook_id, paragraphs_read, last_para, last_at FROM textbook_progress
|
||||
WHERE user_id = ? AND textbook_id IN (SELECT id FROM textbooks WHERE parent_slug = ?)
|
||||
ORDER BY last_at DESC
|
||||
`);
|
||||
|
||||
const textbooks = rows.map(t => {
|
||||
const children = stmtChildren.all(t.slug);
|
||||
if (children.length === 0) {
|
||||
return { ...t, progress: progressById[t.id] || { read: [], last_para: null, last_at: null } };
|
||||
}
|
||||
|
||||
/* Hub: aggregate progress across all child chapters. */
|
||||
const childRows = stmtChildProgress.all(req.user.id, t.slug);
|
||||
const readSet = new Set();
|
||||
let last_para = null;
|
||||
let last_at = null;
|
||||
|
||||
for (const cp of childRows) {
|
||||
let arr = [];
|
||||
try { arr = JSON.parse(cp.paragraphs_read || '[]'); } catch {}
|
||||
for (const item of arr) readSet.add(item);
|
||||
/* childRows ordered by last_at DESC — first row is the most recent. */
|
||||
if (last_para === null) {
|
||||
last_para = cp.last_para;
|
||||
last_at = cp.last_at;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...t,
|
||||
progress: { read: [...readSet], last_para, last_at },
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ textbooks });
|
||||
});
|
||||
|
||||
/* GET /api/textbooks/bookmarks/all — all my bookmarks across textbooks */
|
||||
@@ -123,6 +165,43 @@ router.delete('/bookmarks/:id', (req, res) => {
|
||||
:slug ROUTES (catch-all per textbook)
|
||||
════════════════════════════════════════════════ */
|
||||
|
||||
/* GET /api/textbooks/:slug/children — chapter list for a hub textbook, with per-user progress.
|
||||
Used by hub pages to render chapter cards and progress bars. */
|
||||
// @public-by-design: router-level authMiddleware (line 7) covers all routes in this file
|
||||
router.get('/:slug/children', (req, res) => {
|
||||
const hub = db.prepare('SELECT id FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug);
|
||||
if (!hub) return res.status(404).json({ error: 'Учебник не найден' });
|
||||
|
||||
const children = db.prepare(`
|
||||
SELECT t.id, t.slug, t.title, t.description, t.html_path, t.para_count, t.sort_order, t.color
|
||||
FROM textbooks t
|
||||
WHERE t.parent_slug = ? AND t.is_active = 1
|
||||
ORDER BY t.sort_order
|
||||
`).all(req.params.slug);
|
||||
|
||||
const progressRows = db.prepare(`
|
||||
SELECT textbook_id, paragraphs_read, last_para, last_at
|
||||
FROM textbook_progress
|
||||
WHERE user_id = ? AND textbook_id IN (
|
||||
SELECT id FROM textbooks WHERE parent_slug = ? AND is_active = 1
|
||||
)
|
||||
`).all(req.user.id, req.params.slug);
|
||||
|
||||
const progMap = {};
|
||||
for (const p of progressRows) {
|
||||
let arr = [];
|
||||
try { arr = JSON.parse(p.paragraphs_read || '[]'); } catch {}
|
||||
progMap[p.textbook_id] = { read: arr, last_para: p.last_para, last_at: p.last_at };
|
||||
}
|
||||
|
||||
res.json({
|
||||
children: children.map(c => ({
|
||||
...c,
|
||||
progress: progMap[c.id] || { read: [], last_para: null, last_at: null },
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* GET /api/textbooks/:slug — single textbook detail */
|
||||
router.get('/:slug', (req, res) => {
|
||||
const t = db.prepare('SELECT * FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug);
|
||||
|
||||
Reference in New Issue
Block a user