5 SDD batches archived: - Batch 1: UI Polish (10 features, 14 tasks) - Batch 2: Study System (8 features, 23 tasks) - Batch 3: Infrastructure (5 features, 22 tasks) - Batch 4: AI Advanced (5 features, 30 tasks) — RAG with @xenova/transformers - Batch 5: Core Features (5 features, 19 tasks) 37 bugs fixed from comprehensive code review (11 CRITICAL, 12 HIGH, 14 MEDIUM/LOW): - SSE streaming now works (event.token check) - API keys no longer exposed via GET /api/models - FTS5 injection sanitized - DB backup/restore with admin auth - Buddy mode wired (buddy_meta column) - Exam auto-submit stale closure fixed - CSS variables aligned with design tokens - Progress data corruption fixed - WebSocket protocol auto-detection - Tests infrastructure completed (vitest + node:test)
178 lines
6.4 KiB
JavaScript
178 lines
6.4 KiB
JavaScript
const express = require('express');
|
|
const db = require('../db');
|
|
const { broadcast } = require('../lib/broadcast');
|
|
const PDFDocument = require('pdfkit');
|
|
const router = express.Router();
|
|
|
|
// GET /api/progress — all topics with pct calculation
|
|
router.get('/', (req, res) => {
|
|
try {
|
|
const rows = db.prepare('SELECT * FROM progress ORDER BY topic').all();
|
|
const result = rows.map(r => {
|
|
const pct = r.exercises_done > 0 ? Math.round((r.exercises_correct / r.exercises_done) * 100) : 0;
|
|
return {
|
|
topic: r.topic,
|
|
exercises_done: r.exercises_done,
|
|
exercises_correct: r.exercises_correct,
|
|
percentage: pct,
|
|
last_session: r.last_session,
|
|
notes: r.notes,
|
|
};
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[progress] list error:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// PUT /api/progress/:topic — update exercises (body: { correct: bool })
|
|
router.put('/:topic', (req, res) => {
|
|
const topic = req.params.topic;
|
|
const { correct } = req.body;
|
|
|
|
if (correct === undefined) {
|
|
return res.status(400).json({ error: 'correct is required' });
|
|
}
|
|
|
|
try {
|
|
const existing = db.prepare('SELECT * FROM progress WHERE topic = ?').get(topic);
|
|
if (existing) {
|
|
db.prepare(`
|
|
UPDATE progress SET
|
|
exercises_done = exercises_done + 1,
|
|
exercises_correct = exercises_correct + ?,
|
|
last_session = datetime('now')
|
|
WHERE topic = ?
|
|
`).run(correct === true ? 1 : 0, topic);
|
|
} else {
|
|
db.prepare(`
|
|
INSERT INTO progress (topic, exercises_done, exercises_correct, last_session, notes)
|
|
VALUES (?, 1, ?, datetime('now'), '[]')
|
|
`).run(topic, correct === true ? 1 : 0);
|
|
}
|
|
|
|
const row = db.prepare('SELECT * FROM progress WHERE topic = ?').get(topic);
|
|
const pct = row.exercises_done > 0 ? Math.round((row.exercises_correct / row.exercises_done) * 100) : 0;
|
|
const result = { ...row, percentage: pct };
|
|
broadcast({ type: 'progress_update', data: result });
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[progress] update error:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/progress/:topic — reset
|
|
router.delete('/:topic', (req, res) => {
|
|
const topic = req.params.topic;
|
|
|
|
try {
|
|
const info = db.prepare('DELETE FROM progress WHERE topic = ?').run(topic);
|
|
if (info.changes === 0) {
|
|
return res.status(404).json({ error: 'Topic not found' });
|
|
}
|
|
res.json({ deleted: true });
|
|
} catch (err) {
|
|
console.error('[progress] delete error:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/progress/sessions — UPSERT daily minutes
|
|
router.post('/sessions', (req, res) => {
|
|
const { date, minutes, topic } = req.body;
|
|
if (!date || minutes === undefined) {
|
|
return res.status(400).json({ error: 'date and minutes are required' });
|
|
}
|
|
try {
|
|
// NULL != NULL in SQLite UNIQUE constraint — explicit NULL handling keeps things working
|
|
const existing = db.prepare('SELECT * FROM study_sessions WHERE session_date = ? AND (topic = ? OR (topic IS NULL AND ? IS NULL))').get(date, topic || null, topic || null);
|
|
if (existing) {
|
|
db.prepare('UPDATE study_sessions SET minutes = minutes + ? WHERE id = ?').run(minutes, existing.id);
|
|
} else {
|
|
db.prepare('INSERT INTO study_sessions (session_date, minutes, topic) VALUES (?, ?, ?)').run(date, minutes, topic || null);
|
|
}
|
|
const row = db.prepare('SELECT * FROM study_sessions WHERE session_date = ? AND (topic = ? OR (topic IS NULL AND ? IS NULL))').get(date, topic || null, topic || null);
|
|
res.json(row);
|
|
} catch (err) {
|
|
console.error('[progress] session error:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/progress/heatmap — aggregated study minutes per day
|
|
router.get('/heatmap', (req, res) => {
|
|
try {
|
|
const days = parseInt(req.query.days, 10) || 365;
|
|
const since = new Date();
|
|
since.setDate(since.getDate() - days);
|
|
const sinceStr = since.toISOString().split('T')[0];
|
|
const rows = db.prepare(
|
|
'SELECT session_date as date, SUM(minutes) as minutes FROM study_sessions WHERE session_date >= ? GROUP BY session_date ORDER BY session_date'
|
|
).all(sinceStr);
|
|
res.json(rows);
|
|
} catch (err) {
|
|
console.error('[progress] heatmap error:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/progress/exam/pdf — generate simulated exam PDF
|
|
router.get('/exam/pdf', (req, res) => {
|
|
try {
|
|
const rows = db.prepare(
|
|
'SELECT * FROM progress ORDER BY last_session DESC LIMIT 20'
|
|
).all();
|
|
|
|
if (!rows.length) {
|
|
return res.status(404).json({ error: 'No progress data' });
|
|
}
|
|
|
|
const doc = new PDFDocument({ margin: 50 });
|
|
const filename = `examen-simulado-${new Date().toISOString().slice(0, 10)}.pdf`;
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
doc.pipe(res);
|
|
res.on('error', () => { try { doc.end(); } catch {} });
|
|
|
|
// Header
|
|
doc.fontSize(22).text('Examen Simulado', 50, 50);
|
|
doc.fontSize(12).text(`Generado: ${new Date().toLocaleDateString('es-ES')}`, 50, 80);
|
|
doc.moveDown(2);
|
|
|
|
// Topics table
|
|
doc.fontSize(14).text('Temas evaluados', 50, doc.y);
|
|
doc.moveDown(0.5);
|
|
doc.fontSize(10);
|
|
rows.forEach((row, idx) => {
|
|
const pct = row.exercises_done > 0 ? Math.round((row.exercises_correct / row.exercises_done) * 100) : 0;
|
|
doc.text(`${idx + 1}. ${row.topic} — ${pct}% (${row.exercises_correct}/${row.exercises_done})`, 60, doc.y);
|
|
});
|
|
|
|
if (rows.length === 20) {
|
|
doc.moveDown(1);
|
|
doc.fontSize(9).fillColor('gray').text('Nota: se muestran los 20 temas más recientes.', 50, doc.y);
|
|
doc.fillColor('black');
|
|
}
|
|
|
|
doc.moveDown(2);
|
|
doc.fontSize(14).text('Ejercicios de muestra', 50, doc.y);
|
|
doc.moveDown(0.5);
|
|
doc.fontSize(11);
|
|
rows.slice(0, 10).forEach((row, idx) => {
|
|
doc.text(`${idx + 1}. Describe el tema "${row.topic}" con sus puntos clave.`, 60, doc.y);
|
|
doc.moveDown(0.3);
|
|
doc.text(' Respuesta: _______________________________________________', 60, doc.y);
|
|
doc.moveDown(1);
|
|
});
|
|
|
|
doc.end();
|
|
} catch (err) {
|
|
console.error('[progress] exam/pdf error:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|