const db = require('../db/db'); const { stripTags } = require('../utils/sanitize'); /* helper: find or create topic by name within a subject */ function resolveTopicId(subjectId, topicId, topicName) { if (topicId) return Number(topicId); if (!topicName?.trim()) return null; const name = topicName.trim(); const existing = db.prepare('SELECT id FROM topics WHERE subject_id = ? AND LOWER(name) = LOWER(?)').get(subjectId, name); if (existing) return existing.id; return db.prepare('INSERT INTO topics (subject_id, name) VALUES (?, ?)').run(subjectId, name).lastInsertRowid; } /* ── GET /api/questions?subject=bio&topic_id=1&sort=diff_asc&page=1&limit=50 */ function list(req, res) { const { subject, topic_id, sort, source_type, q, difficulty, type } = req.query; const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100)); const page = Math.max(1, Number(req.query.page) || 1); const offset = (page - 1) * limit; const subj = subject ? db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject) : null; if (subject && !subj) return res.status(404).json({ error: 'Subject not found' }); const ORDER = { date_desc: 'q.id DESC', date_asc: 'q.id ASC', diff_asc: 'q.difficulty ASC, q.id DESC', diff_desc: 'q.difficulty DESC, q.id DESC', }; const orderBy = ORDER[sort] || 'q.id DESC'; let where = 'WHERE 1=1'; const args = []; if (subj) { where += ' AND q.subject_id = ?'; args.push(subj.id); } if (topic_id) { where += ' AND q.topic_id = ?'; args.push(topic_id); } if (source_type) { where += ' AND q.source_type = ?'; args.push(source_type); } if (difficulty) { where += ' AND q.difficulty = ?'; args.push(Number(difficulty)); } if (type) { where += ' AND q.type = ?'; args.push(type); } if (q?.trim()) { where += ' AND q.text LIKE ?'; args.push(`%${q.trim()}%`); } const { total } = db.prepare(`SELECT COUNT(*) AS total FROM questions q ${where}`).get(...args); const sql = ` SELECT q.id, q.text, q.type, q.correct_text, q.difficulty, q.explanation, q.image, q.year, q.source_type, q.allow_html, t.name AS topic, t.id AS topic_id, s.name AS subject_name, s.slug AS subject_slug, (SELECT json_group_array(json_object( 'id', o.id, 'text', o.text, 'is_correct', o.is_correct, 'order_index', o.order_index, 'match_pair', o.match_pair ) ORDER BY o.order_index) FROM options o WHERE o.question_id = q.id) AS options_json FROM questions q LEFT JOIN topics t ON t.id = q.topic_id LEFT JOIN subjects s ON s.id = q.subject_id ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ? `; const rows = db.prepare(sql).all(...args, limit, offset).map(r => ({ ...r, options: JSON.parse(r.options_json || '[]'), options_json: undefined, })); res.json({ rows, total, page, limit }); } /* ── POST /api/questions ─────────────────────────────────────────────── */ function create(req, res) { const { subject_slug, topic_id, topic_name, type = 'single', correct_text, difficulty = 1, explanation, options, image } = req.body; const text = stripTags((req.body.text || '').trim()); if (!subject_slug || !text) return res.status(400).json({ error: 'subject_slug and text are required' }); if (text.length > 2000) return res.status(400).json({ error: 'text too long (max 2000 chars)' }); if (type !== 'short_answer' && !options?.length) return res.status(400).json({ error: 'options required for this question type' }); if (type !== 'short_answer' && type !== 'matching' && !options.some(o => o.is_correct)) return res.status(400).json({ error: 'At least one option must be correct' }); const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug); if (!subj) return res.status(404).json({ error: 'Subject not found' }); const resolvedTopicId = resolveTopicId(subj.id, topic_id, topic_name); try { const qid = db.transaction(() => { const { lastInsertRowid } = db.prepare( 'INSERT INTO questions (subject_id, topic_id, text, type, correct_text, difficulty, explanation, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ).run(subj.id, resolvedTopicId, text, type, type === 'short_answer' ? (correct_text || null) : null, difficulty, explanation || null, image || null); const insertOpt = db.prepare( 'INSERT INTO options (question_id, text, is_correct, order_index, match_pair) VALUES (?, ?, ?, ?, ?)' ); options.forEach((o, i) => insertOpt.run(lastInsertRowid, o.text, type === 'matching' ? 0 : (o.is_correct ? 1 : 0), i, o.match_pair || null)); return lastInsertRowid; })(); res.status(201).json({ id: qid }); } catch (err) { console.error('[question create]', err.message); res.status(500).json({ error: 'Ошибка создания вопроса' }); } } /* ── POST /api/questions/:id/duplicate ──────────────────────────────── */ function duplicate(req, res) { const q = db.prepare(` SELECT q.*, s.slug AS subject_slug FROM questions q LEFT JOIN subjects s ON s.id = q.subject_id WHERE q.id = ? `).get(req.params.id); if (!q) return res.status(404).json({ error: 'Question not found' }); const opts = db.prepare('SELECT text, is_correct, order_index FROM options WHERE question_id = ? ORDER BY order_index').all(q.id); try { const newId = db.transaction(() => { const { lastInsertRowid } = db.prepare( 'INSERT INTO questions (subject_id, topic_id, text, difficulty, explanation, type, correct_text, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ).run(q.subject_id, q.topic_id, q.text + ' (копия)', q.difficulty, q.explanation, q.type, q.correct_text, q.image); const ins = db.prepare('INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?, ?, ?, ?)'); opts.forEach(o => ins.run(lastInsertRowid, o.text, o.is_correct, o.order_index)); return lastInsertRowid; })(); res.status(201).json({ id: newId }); } catch (err) { console.error('[question duplicate]', err.message); res.status(500).json({ error: 'Ошибка дублирования вопроса' }); } } /* ── PUT /api/questions/:id ──────────────────────────────────────────── */ function update(req, res) { const { type, correct_text, difficulty, explanation, topic_id, topic_name, options, image } = req.body; const text = req.body.text !== undefined ? stripTags(String(req.body.text).trim()) : undefined; const qid = req.params.id; if (text !== undefined && text.length > 2000) return res.status(400).json({ error: 'text too long (max 2000 chars)' }); const q = db.prepare('SELECT id, subject_id, type AS oldType FROM questions WHERE id = ?').get(qid); if (!q) return res.status(404).json({ error: 'Question not found' }); const resolvedTopicId = resolveTopicId(q.subject_id, topic_id, topic_name); const qtype = type || q.oldType || 'single'; try { db.transaction(() => { db.prepare( 'UPDATE questions SET text = ?, type = ?, correct_text = ?, difficulty = ?, explanation = ?, topic_id = ?, image = ? WHERE id = ?' ).run(text, qtype, qtype === 'short_answer' ? (correct_text || null) : null, difficulty, explanation || null, resolvedTopicId, image !== undefined ? (image || null) : db.prepare('SELECT image FROM questions WHERE id = ?').get(qid)?.image, qid); if (options?.length) { if (qtype !== 'matching' && !options.some(o => o.is_correct)) throw Object.assign(new Error('At least one option must be correct'), { status: 400 }); db.prepare('DELETE FROM options WHERE question_id = ?').run(qid); const ins = db.prepare( 'INSERT INTO options (question_id, text, is_correct, order_index, match_pair) VALUES (?, ?, ?, ?, ?)' ); options.forEach((o, i) => ins.run(qid, o.text, qtype === 'matching' ? 0 : (o.is_correct ? 1 : 0), i, o.match_pair || null)); } })(); res.json({ id: Number(qid) }); } catch (err) { console.error('[question update]', err.message); res.status(err.status || 500).json({ error: err.status ? err.message : 'Ошибка обновления' }); } } /* ── DELETE /api/questions/:id ───────────────────────────────────────── */ function remove(req, res) { const q = db.prepare('SELECT id FROM questions WHERE id = ?').get(req.params.id); if (!q) return res.status(404).json({ error: 'Question not found' }); db.prepare('DELETE FROM questions WHERE id = ?').run(req.params.id); res.json({ deleted: Number(req.params.id) }); } /* ── POST /api/questions/import (CSV upload) ────────────────────────── */ /* CSV format (semicolon-separated, first row = header): subject_slug;topic;text;difficulty;type;opt1;c1;opt2;c2;opt3;c3;opt4;c4;correct_text;explanation;year - type: single | multi | true_false | short_answer - c1-c4: 1 = correct, 0 = wrong (ignored for short_answer) - correct_text: used only for short_answer type */ function importCSV(req, res) { if (!req.file) return res.status(400).json({ error: 'CSV file required' }); const raw = req.file.buffer.toString('utf-8').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = raw.split('\n').map(l => l.trim()).filter(l => l.length); if (lines.length < 2) return res.status(400).json({ error: 'CSV is empty or has only header' }); // skip header row const dataLines = lines.slice(1); const errors = []; let imported = 0; const insQ = db.prepare('INSERT INTO questions (subject_id, topic_id, text, type, correct_text, difficulty, explanation, year) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); const insOpt = db.prepare('INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?, ?, ?, ?)'); try { db.transaction(() => { for (let i = 0; i < dataLines.length; i++) { const cols = dataLines[i].split(';'); const [subject_slug, topic, text, diffStr, type = 'single', opt1 = '', c1 = '0', opt2 = '', c2 = '0', opt3 = '', c3 = '0', opt4 = '', c4 = '0', correct_text = '', explanation = '', yearStr = ''] = cols.map(c => c.trim()); if (!subject_slug || !text) { errors.push(`Строка ${i + 2}: пропущен subject_slug или text`); continue; } const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug); if (!subj) { errors.push(`Строка ${i + 2}: неизвестный subject_slug «${subject_slug}»`); continue; } const difficulty = Math.min(3, Math.max(1, Number(diffStr) || 1)); const year = yearStr ? Number(yearStr) || null : null; const qtype = ['single', 'multi', 'true_false', 'short_answer'].includes(type) ? type : 'single'; const topicId = resolveTopicId(subj.id, null, topic || null); const isShort = qtype === 'short_answer'; const options = [ { text: opt1, is_correct: c1 === '1' }, { text: opt2, is_correct: c2 === '1' }, { text: opt3, is_correct: c3 === '1' }, { text: opt4, is_correct: c4 === '1' }, ].filter(o => o.text.length > 0); if (!isShort && options.length < 2) { errors.push(`Строка ${i + 2}: нужно минимум 2 варианта ответа`); continue; } if (!isShort && !options.some(o => o.is_correct)) { errors.push(`Строка ${i + 2}: нет правильного варианта (укажи 1 в столбце c1–c4)`); continue; } const { lastInsertRowid: qid } = insQ.run( subj.id, topicId, text, qtype, isShort ? (correct_text || null) : null, difficulty, explanation || null, year ); if (!isShort) options.forEach((o, idx) => insOpt.run(qid, o.text, o.is_correct ? 1 : 0, idx)); imported++; } })(); } catch (e) { console.error('[question import]', e.message); return res.status(500).json({ error: 'Ошибка импорта' }); } res.json({ imported, errors }); } module.exports = { list, create, duplicate, update, remove, importCSV };