// ============================================================ // LEARNING ADMIN ROUTES — CMS for categories, content, quizzes // ============================================================ var express = require('express'); var router = express.Router(); var db = require('../db/database'); var imageLinks = require('../utils/generatedImageLinks'); var { authMiddleware, moderatorMiddleware } = require('../middleware/auth'); var { generateContentEmbedding, isEmbeddingsAvailable } = require('../utils/embeddings'); router.use(authMiddleware); router.use(moderatorMiddleware); // ── Helpers ────────────────────────────────────────────────── function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 100); } var ALLOWED_SLUG_TABLES = ['learning_categories', 'learning_content']; var ALLOWED_QUESTION_TYPES = ['mcq', 'true_false', 'multi']; async function uniqueSlug(table, base) { if (ALLOWED_SLUG_TABLES.indexOf(table) === -1) throw new Error('Invalid table for slug generation'); var slug = slugify(base); var exists = await db.get('SELECT id FROM ' + table + ' WHERE slug = ?', [slug]); if (!exists) return slug; var i = 2; while (true) { var attempt = slug + '-' + i; var exists2 = await db.get('SELECT id FROM ' + table + ' WHERE slug = ?', [attempt]); if (!exists2) return attempt; i++; if (i > 100) return slug + '-' + Date.now(); } } // ============================================================ // CATEGORIES — CRUD // ============================================================ router.get('/categories', async function(req, res) { try { var categories = await db.all( `SELECT c.*, (SELECT COUNT(*) FROM learning_content lc WHERE lc.category_id = c.id) as content_count FROM learning_categories c ORDER BY c.sort_order ASC, c.name ASC`, [] ); res.json({ success: true, categories: categories }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); router.post('/categories', async function(req, res) { try { var { name, description, sort_order } = req.body; if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' }); var existing = await db.get('SELECT id, slug FROM learning_categories WHERE LOWER(name) = LOWER(?)', [name.trim()]); if (existing) return res.json({ success: true, id: existing.id, slug: existing.slug, existing: true }); var slug = await uniqueSlug('learning_categories', name.trim()); var result = await db.run( 'INSERT INTO learning_categories (name, slug, description, sort_order) VALUES (?, ?, ?, ?)', [name.trim(), slug, description || '', sort_order || 0] ); res.json({ success: true, id: result.lastInsertRowid, slug: slug }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); router.put('/categories/:id', async function(req, res) { try { var { name, description, sort_order } = req.body; var cat = await db.get('SELECT * FROM learning_categories WHERE id = ?', [req.params.id]); if (!cat) return res.status(404).json({ error: 'Category not found' }); await db.run( 'UPDATE learning_categories SET name = ?, description = ?, sort_order = ? WHERE id = ?', [name || cat.name, description !== undefined ? description : cat.description, sort_order !== undefined ? sort_order : cat.sort_order, cat.id] ); res.json({ success: true }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); router.delete('/categories/:id', async function(req, res) { try { // Set content in this category to uncategorized (NULL) await db.run('UPDATE learning_content SET category_id = NULL WHERE category_id = ?', [req.params.id]); await db.run('DELETE FROM learning_categories WHERE id = ?', [req.params.id]); res.json({ success: true }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); // ============================================================ // CONTENT — CRUD // ============================================================ router.get('/content', async function(req, res) { try { var content = await db.all( `SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.published, c.category_id, c.created_at, c.updated_at, cat.name as category_name, u.name as author_name, (SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count FROM learning_content c LEFT JOIN learning_categories cat ON c.category_id = cat.id LEFT JOIN users u ON c.author_id = u.id ORDER BY c.updated_at DESC`, [] ); res.json({ success: true, content: content }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); router.get('/content/:id', async function(req, res) { try { var item = await db.get( `SELECT c.*, cat.name as category_name FROM learning_content c LEFT JOIN learning_categories cat ON c.category_id = cat.id WHERE c.id = ?`, [req.params.id] ); if (!item) return res.status(404).json({ error: 'Content not found' }); // Get questions + options var questions = await db.all( 'SELECT * FROM learning_questions WHERE content_id = ? ORDER BY sort_order ASC', [item.id] ); for (var i = 0; i < questions.length; i++) { questions[i].options = await db.all( 'SELECT * FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC', [questions[i].id] ); } item.questions = questions; res.json({ success: true, content: item }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); router.post('/content', async function(req, res) { try { var { title, body, category_id, subject, content_type, published } = req.body; if (!title || !title.trim()) return res.status(400).json({ error: 'Title required' }); var slug = await uniqueSlug('learning_content', title.trim()); var client = await db.pool.connect(); var contentId; try { await client.query('BEGIN'); var imageIds = await imageLinks.validateLearning(client, body, req.user.id); var result = await client.query( 'INSERT INTO learning_content (title, slug, body, category_id, subject, content_type, published, author_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id', [title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id]); contentId = result.rows[0].id; await imageLinks.setLinks(client, contentId, imageIds); await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); } // Generate embedding asynchronously (don't block response) if (isEmbeddingsAvailable() && body && body.trim()) { generateContentEmbedding({ title: title.trim(), subject: subject || '', body: body }) .then(function(embedding) { return db.query( 'UPDATE learning_content SET embedding = $1 WHERE id = $2', [JSON.stringify(embedding), contentId] ); }) .then(function() { console.log('[Embeddings] Generated for content ID:', contentId); }) .catch(function(err) { console.error('[Embeddings] Failed for content ID ' + contentId + ':', err.message); }); } res.json({ success: true, id: contentId, slug: slug }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Internal server error' }); } }); router.put('/content/:id', async function(req, res) { try { var { title, body, category_id, subject, content_type, published } = req.body; var client = await db.pool.connect(); try { await client.query('BEGIN'); var item = (await client.query('SELECT * FROM learning_content WHERE id=$1 FOR UPDATE', [req.params.id])).rows[0]; if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Content not found' }); } // All omitted fields must come from the locked row, including publication state. var newTitle = title !== undefined ? title : item.title; var newBody = body !== undefined ? body : item.body; var newSubject = subject !== undefined ? subject : item.subject; var imageIds = await imageLinks.validateLearning(client, newBody, req.user.id, item.id); await client.query( 'UPDATE learning_content SET title = $1, body = $2, category_id = $3, subject = $4, content_type = $5, published = $6, updated_at = NOW() WHERE id = $7', [ newTitle, newBody, category_id !== undefined ? (category_id || null) : item.category_id, newSubject, content_type !== undefined ? content_type : item.content_type, published !== undefined ? published : item.published, item.id ] ); await imageLinks.setLinks(client, item.id, imageIds); await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); } // Regenerate embedding if title/body/subject changed (async, don't block) if (isEmbeddingsAvailable() && (title !== undefined || body !== undefined || subject !== undefined)) { if (newBody && newBody.trim()) { generateContentEmbedding({ title: newTitle, subject: newSubject, body: newBody }) .then(function(embedding) { return db.query( 'UPDATE learning_content SET embedding = $1 WHERE id = $2', [JSON.stringify(embedding), item.id] ); }) .then(function() { console.log('[Embeddings] Regenerated for content ID:', item.id); }) .catch(function(err) { console.error('[Embeddings] Failed to regenerate for content ID ' + item.id + ':', err.message); }); } } res.json({ success: true }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Internal server error' }); } }); router.delete('/content/:id', async function(req, res) { try { await db.run('DELETE FROM learning_content WHERE id = ?', [req.params.id]); res.json({ success: true }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); // ============================================================ // QUESTIONS — CRUD (nested under content) // ============================================================ router.post('/content/:contentId/questions', async function(req, res) { try { var { question_text, question_type, explanation, options } = req.body; if (!question_text) return res.status(400).json({ error: 'Question text required' }); if (question_type === undefined) question_type = 'mcq'; if (!ALLOWED_QUESTION_TYPES.includes(question_type)) return res.status(400).json({ error: 'Invalid question type' }); // Get max sort_order var maxOrder = await db.get('SELECT COALESCE(MAX(sort_order), -1) as mx FROM learning_questions WHERE content_id = ?', [req.params.contentId]); var sortOrder = (maxOrder ? maxOrder.mx : -1) + 1; var result = await db.run( 'INSERT INTO learning_questions (content_id, question_text, question_type, explanation, sort_order) VALUES (?, ?, ?, ?, ?)', [req.params.contentId, question_text, question_type, explanation || '', sortOrder] ); var questionId = result.lastInsertRowid; // Insert options if provided if (options && Array.isArray(options)) { for (var i = 0; i < options.length; i++) { var opt = options[i]; await db.run( 'INSERT INTO learning_options (question_id, option_text, is_correct, explanation, sort_order) VALUES (?, ?, ?, ?, ?)', [questionId, opt.option_text, opt.is_correct ? true : false, opt.explanation || '', i] ); } } res.json({ success: true, id: questionId }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); router.put('/questions/:id', async function(req, res) { try { var q = await db.get('SELECT * FROM learning_questions WHERE id = ?', [req.params.id]); if (!q) return res.status(404).json({ error: 'Question not found' }); var { question_text, question_type, explanation, options } = req.body; if (question_type === undefined) question_type = q.question_type; if (!ALLOWED_QUESTION_TYPES.includes(question_type)) return res.status(400).json({ error: 'Invalid question type' }); await db.run( 'UPDATE learning_questions SET question_text = ?, question_type = ?, explanation = ? WHERE id = ?', [question_text || q.question_text, question_type, explanation !== undefined ? explanation : q.explanation, q.id] ); // Replace options if provided if (options && Array.isArray(options)) { await db.run('DELETE FROM learning_options WHERE question_id = ?', [q.id]); for (var i = 0; i < options.length; i++) { var opt = options[i]; await db.run( 'INSERT INTO learning_options (question_id, option_text, is_correct, explanation, sort_order) VALUES (?, ?, ?, ?, ?)', [q.id, opt.option_text, opt.is_correct ? true : false, opt.explanation || '', i] ); } } res.json({ success: true }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); router.delete('/questions/:id', async function(req, res) { try { await db.run('DELETE FROM learning_questions WHERE id = ?', [req.params.id]); res.json({ success: true }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); // ============================================================ // STATS // ============================================================ router.get('/stats', async function(req, res) { try { var totalContent = await db.get('SELECT COUNT(*) as count FROM learning_content', []); var published = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE published = true', []); var totalCategories = await db.get('SELECT COUNT(*) as count FROM learning_categories', []); var totalQuizzes = await db.get("SELECT COUNT(DISTINCT content_id) as count FROM learning_questions", []); var totalAttempts = await db.get('SELECT COUNT(*) as count FROM learning_progress', []); var withEmbeddings = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL', []); res.json({ success: true, stats: { totalContent: parseInt(totalContent.count), publishedContent: parseInt(published.count), totalCategories: parseInt(totalCategories.count), totalQuizzes: parseInt(totalQuizzes.count), totalAttempts: parseInt(totalAttempts.count), withEmbeddings: parseInt(withEmbeddings.count), embeddingsEnabled: isEmbeddingsAvailable() } }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } }); // ============================================================ // EMBEDDINGS — Backfill & Management // ============================================================ // Generate embeddings for all content (or just missing ones) router.post('/embeddings/generate', async function(req, res) { try { if (!isEmbeddingsAvailable()) { return res.status(400).json({ error: 'Embeddings not configured. Set LITELLM_API_BASE, VERTEX_PROJECT, or OPENAI_API_KEY' }); } var { regenerateAll } = req.body; // Get content without embeddings (or all if regenerateAll=true) var whereClause = regenerateAll ? '' : 'WHERE embedding IS NULL'; var content = await db.all( 'SELECT id, title, subject, body FROM learning_content ' + whereClause + ' ORDER BY id ASC', [] ); if (content.length === 0) { return res.json({ success: true, message: 'All content already has embeddings', processed: 0 }); } // Process in background var processed = 0; var failed = 0; console.log('[Embeddings] Starting batch generation for ' + content.length + ' items...'); // Don't await — run in background async function generateEmbeddingsInBackground() { for (var i = 0; i < content.length; i++) { var item = content[i]; try { if (!item.body || !item.body.trim()) { console.log('[Embeddings] Skipping empty content ID:', item.id); continue; } var embedding = await generateContentEmbedding(item); await db.query( 'UPDATE learning_content SET embedding = $1 WHERE id = $2', [JSON.stringify(embedding), item.id] ); processed++; console.log('[Embeddings] Generated ' + processed + '/' + content.length + ' (ID: ' + item.id + ')'); } catch (err) { failed++; console.error('[Embeddings] Failed for content ID ' + item.id + ':', err.message); } } console.log('[Embeddings] Batch complete: ' + processed + ' succeeded, ' + failed + ' failed'); // Create index if we have enough embeddings now if (processed >= 10) { try { var lists = Math.max(10, Math.floor(Math.sqrt(processed))); await db.query( 'CREATE INDEX IF NOT EXISTS idx_learning_content_embedding ON learning_content USING ivfflat (embedding vector_cosine_ops) WITH (lists = ' + lists + ')' ); console.log('[Embeddings] Vector index created/updated'); } catch (e) { console.error('[Embeddings] Index creation failed:', e.message); } } } generateEmbeddingsInBackground(); res.json({ success: true, message: 'Embedding generation started in background', total: content.length }); } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Request failed' }); } }); // Check embedding status router.get('/embeddings/status', async function(req, res) { try { var total = await db.get('SELECT COUNT(*) as count FROM learning_content', []); var withEmbeddings = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL', []); var missing = parseInt(total.count) - parseInt(withEmbeddings.count); res.json({ success: true, enabled: isEmbeddingsAvailable(), total: parseInt(total.count), withEmbeddings: parseInt(withEmbeddings.count), missing: missing, model: process.env.EMBEDDING_MODEL || 'vertex_ai/text-embedding-005', dimensions: parseInt(process.env.EMBEDDING_DIMENSIONS) || 768 }); } catch (err) { res.status(500).json({ error: 'Request failed' }); } }); module.exports = router;