423 lines
18 KiB
JavaScript
423 lines
18 KiB
JavaScript
// ============================================================
|
|
// LEARNING ADMIN ROUTES — CMS for categories, content, quizzes
|
|
// ============================================================
|
|
|
|
var express = require('express');
|
|
var router = express.Router();
|
|
var db = require('../db/database');
|
|
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'];
|
|
|
|
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 result = await db.run(
|
|
'INSERT INTO learning_content (title, slug, body, category_id, subject, content_type, published, author_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
[title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id]
|
|
);
|
|
|
|
var contentId = result.lastInsertRowid;
|
|
|
|
// 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(500).json({ error: 'Internal server error' }); }
|
|
});
|
|
|
|
router.put('/content/:id', async function(req, res) {
|
|
try {
|
|
var item = await db.get('SELECT * FROM learning_content WHERE id = ?', [req.params.id]);
|
|
if (!item) return res.status(404).json({ error: 'Content not found' });
|
|
|
|
var { title, body, category_id, subject, content_type, published } = req.body;
|
|
|
|
var newTitle = title !== undefined ? title : item.title;
|
|
var newBody = body !== undefined ? body : item.body;
|
|
var newSubject = subject !== undefined ? subject : item.subject;
|
|
|
|
await db.run(
|
|
'UPDATE learning_content SET title = ?, body = ?, category_id = ?, subject = ?, content_type = ?, published = ?, updated_at = NOW() WHERE id = ?',
|
|
[
|
|
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
|
|
]
|
|
);
|
|
|
|
// 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(500).json({ error: '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' });
|
|
|
|
// 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 || 'mcq', 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;
|
|
|
|
await db.run(
|
|
'UPDATE learning_questions SET question_text = ?, question_type = ?, explanation = ? WHERE id = ?',
|
|
[question_text || q.question_text, question_type || q.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;
|