const fs = require('fs'); const path = require('path'); const db = require('../db'); const config = require('../config'); const Database = require('better-sqlite3'); let idb = null; function getIntelligenceDb() { if (idb) return idb; const configDir = path.resolve(__dirname, '..', '..'); const rawPath = process.env.INTELLIGENCE_DB || (config.intelligence_db ? (path.isAbsolute(config.intelligence_db) ? config.intelligence_db : path.resolve(configDir, config.intelligence_db)) : path.resolve(configDir, 'intelligence.sqlite')); if (!fs.existsSync(rawPath)) return null; idb = new Database(rawPath); return idb; } const adminUser = (config.admin && config.admin.username) || 'admin'; const adminPass = (config.admin && config.admin.password) || 'changeme'; function checkAuth(request, reply) { const header = request.headers['authorization'] || ''; if (!header.startsWith('Basic ')) { reply.header('WWW-Authenticate', 'Basic realm="Duriin Admin"'); reply.code(401).send('Unauthorized'); return false; } const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); const colon = decoded.indexOf(':'); const user = decoded.slice(0, colon); const pass = decoded.slice(colon + 1); if (user !== adminUser || pass !== adminPass) { reply.header('WWW-Authenticate', 'Basic realm="Duriin Admin"'); reply.code(401).send('Unauthorized'); return false; } return true; } const htmlPath = path.join(__dirname, '..', '..', 'admin.html'); async function adminRoutes(fastify) { fastify.get('/admin', async (request, reply) => { if (!checkAuth(request, reply)) return; reply.type('text/html'); return fs.createReadStream(htmlPath); }); // list articles — all of them, not just the ones with embeddings fastify.get('/admin/api/articles', async (request, reply) => { if (!checkAuth(request, reply)) return; const q = request.query || {}; const limit = Math.min(parseInt(q.limit, 10) || 50, 200); const offset = parseInt(q.offset, 10) || 0; const conditions = []; const params = []; if (q.keyword) { conditions.push('(title LIKE ? OR description LIKE ? OR content LIKE ?)'); const like = `%${q.keyword}%`; params.push(like, like, like); } if (q.source) { conditions.push('source = ?'); params.push(q.source); } if (q.content_status) { if (q.content_status === 'null') { conditions.push('content_status IS NULL'); } else { conditions.push('content_status = ?'); params.push(q.content_status); } } if (q.from) { conditions.push('ingested_at >= ?'); params.push(q.from); } if (q.to) { conditions.push('ingested_at <= ?'); params.push(q.to); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const total = db.prepare(`SELECT COUNT(*) as n FROM articles ${where}`).get(...params).n; params.push(limit, offset); const rows = db.prepare(` SELECT id, title, url, source, pub_date, ingested_at, content_status, is_index_page, has_embedding, language FROM articles ${where} ORDER BY ingested_at DESC, id DESC LIMIT ? OFFSET ? `).all(...params); return { total, rows }; }); fastify.get('/admin/api/articles/:id', async (request, reply) => { if (!checkAuth(request, reply)) return; const row = db.prepare(` SELECT id, title, description, content, url, normalized_title, source, pub_date, pub_date_effective, ingested_at, content_status, content_error, content_attempted_at, content_attempt_count, is_index_page, has_embedding, language, event_id FROM articles WHERE id = ? `).get(request.params.id); if (!row) { reply.code(404); return { error: 'not found' }; } return row; }); fastify.patch('/admin/api/articles/:id', async (request, reply) => { if (!checkAuth(request, reply)) return; const id = parseInt(request.params.id, 10); const body = request.body || {}; // only allow editing these fields const allowed = ['title', 'description', 'content', 'content_status', 'is_index_page', 'language', 'pub_date']; const updates = []; const params = []; for (const key of allowed) { if (Object.prototype.hasOwnProperty.call(body, key)) { updates.push(`${key} = ?`); params.push(body[key]); } } if (updates.length === 0) { reply.code(400); return { error: 'no valid fields provided' }; } params.push(id); db.prepare(`UPDATE articles SET ${updates.join(', ')} WHERE id = ?`).run(...params); return { ok: true }; }); fastify.delete('/admin/api/articles/:id', async (request, reply) => { if (!checkAuth(request, reply)) return; const id = parseInt(request.params.id, 10); // remove embeddings first so foreign key stuff doesnt bite us db.prepare(`DELETE FROM article_embedding_store WHERE article_id = ?`).run(id); db.prepare(`DELETE FROM article_embedding_meta WHERE article_id = ?`).run(id); try { db.prepare(`DELETE FROM article_embeddings WHERE article_id = ?`).run(id); } catch (_) {} db.prepare(`DELETE FROM articles WHERE id = ?`).run(id); return { ok: true }; }); // sources list for filter dropdown fastify.get('/admin/api/sources', async (request, reply) => { if (!checkAuth(request, reply)) return; const rows = db.prepare(`SELECT DISTINCT source FROM articles ORDER BY source`).all(); return rows.map(r => r.source); }); // events fastify.get('/admin/api/events', async (request, reply) => { if (!checkAuth(request, reply)) return; const q = request.query || {}; const limit = Math.min(parseInt(q.limit, 10) || 50, 200); const offset = parseInt(q.offset, 10) || 0; const total = db.prepare(`SELECT COUNT(*) as n FROM events`).get().n; const rows = db.prepare(` SELECT e.id, e.title, e.created_at, COUNT(a.id) as article_count FROM events e LEFT JOIN articles a ON a.event_id = e.id GROUP BY e.id ORDER BY e.created_at DESC LIMIT ? OFFSET ? `).all(limit, offset); return { total, rows }; }); fastify.delete('/admin/api/events/:id', async (request, reply) => { if (!checkAuth(request, reply)) return; const id = parseInt(request.params.id, 10); // detach articles from this event but dont delete the articles themselves db.prepare(`UPDATE articles SET event_id = NULL WHERE event_id = ?`).run(id); db.prepare(`DELETE FROM events WHERE id = ?`).run(id); return { ok: true }; }); fastify.patch('/admin/api/events/:id', async (request, reply) => { if (!checkAuth(request, reply)) return; const id = parseInt(request.params.id, 10); const body = request.body || {}; if (!body.title) { reply.code(400); return { error: 'title is required' }; } db.prepare(`UPDATE events SET title = ? WHERE id = ?`).run(body.title, id); return { ok: true }; }); // intelligence endpoints fastify.get('/admin/api/intelligence/stats', async (request, reply) => { if (!checkAuth(request, reply)) return; const db = getIntelligenceDb(); if (!db) return { available: false }; const queue = db.prepare(`SELECT status, COUNT(*) as n FROM article_queue GROUP BY status`).all(); const knowledge = db.prepare(`SELECT COUNT(*) as n FROM event_knowledge`).get().n; const predictions = db.prepare(`SELECT COUNT(*) as n FROM event_predictions`).get().n; const companies = db.prepare(`SELECT COUNT(*) as n FROM tracked_companies`).get().n; const embeddings = db.prepare(`SELECT COUNT(*) as n FROM company_embeddings`).get().n; let workerRates = []; try { workerRates = db.prepare(` SELECT worker, SUM(CASE WHEN completed_at >= datetime('now', '-5 minutes') THEN 1 ELSE 0 END) as n5m, SUM(CASE WHEN completed_at >= datetime('now', '-1 minute') THEN 1 ELSE 0 END) as n1m FROM worker_events GROUP BY worker `).all(); } catch (_) {} return { available: true, queue, knowledge, predictions, companies, embeddings, workerRates }; }); fastify.get('/admin/api/intelligence/companies', async (request, reply) => { if (!checkAuth(request, reply)) return; const db = getIntelligenceDb(); if (!db) return []; return db.prepare(`SELECT * FROM tracked_companies ORDER BY name`).all(); }); fastify.get('/admin/api/intelligence/knowledge', async (request, reply) => { if (!checkAuth(request, reply)) return; const db = getIntelligenceDb(); if (!db) return { total: 0, rows: [] }; const q = request.query || {}; const limit = Math.min(parseInt(q.limit, 10) || 50, 200); const offset = parseInt(q.offset, 10) || 0; const companyId = q.company_id ? parseInt(q.company_id, 10) : null; const type = q.type || null; const conditions = []; const params = []; if (companyId) { conditions.push('ek.company_id = ?'); params.push(companyId); } if (type) { conditions.push('ek.type = ?'); params.push(type); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const sortCol = q.sort === 'event_date' ? 'ek.event_date' : 'ek.id'; const total = db.prepare(`SELECT COUNT(*) as n FROM event_knowledge ek ${where}`).get(...params).n; const rows = db.prepare(` SELECT ek.id, ek.event_id, ek.type, ek.data, ek.event_date, ek.created_at, tc.name as company_name FROM event_knowledge ek JOIN tracked_companies tc ON tc.id = ek.company_id ${where} ORDER BY ${sortCol} DESC LIMIT ? OFFSET ? `).all(...params, limit, offset); return { total, rows }; }); fastify.get('/admin/api/intelligence/predictions', async (request, reply) => { if (!checkAuth(request, reply)) return; const db = getIntelligenceDb(); if (!db) return { total: 0, rows: [] }; const q = request.query || {}; const limit = Math.min(parseInt(q.limit, 10) || 50, 200); const offset = parseInt(q.offset, 10) || 0; const companyId = q.company_id ? parseInt(q.company_id, 10) : null; const conditions = []; const params = []; if (companyId) { conditions.push('ep.company_id = ?'); params.push(companyId); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const sortCol = q.sort === 'event_date' ? 'ep.event_date' : 'ep.id'; const total = db.prepare(`SELECT COUNT(*) as n FROM event_predictions ep ${where}`).get(...params).n; const rows = db.prepare(` SELECT ep.*, tc.name as company_name FROM event_predictions ep JOIN tracked_companies tc ON tc.id = ep.company_id ${where} ORDER BY ${sortCol} DESC LIMIT ? OFFSET ? `).all(...params, limit, offset); return { total, rows }; }); // intelligence graph — nodes + edges from company_relationships fastify.get('/admin/api/intelligence/graph', async (request, reply) => { if (!checkAuth(request, reply)) return; const idb = getIntelligenceDb(); if (!idb) return { nodes: [], edges: [] }; const edges = idb.prepare(`SELECT * FROM company_relationships`).all(); const trackedIds = new Set(); for (const e of edges) { trackedIds.add(e.from_company_id); if (e.to_company_id) trackedIds.add(e.to_company_id); } const allTracked = idb.prepare(`SELECT id, name, ticker FROM tracked_companies`).all(); // build a lowercase name+alias set so we can exclude untracked entities // that are just unresolved references to tracked companies const trackedNameSet = new Set(); for (const c of allTracked) { trackedNameSet.add(c.name.toLowerCase()); trackedNameSet.add(c.ticker.toLowerCase()); } const untrackedSeen = new Set(); for (const e of edges) { if (!e.to_company_id && e.to_entity) { if (!trackedNameSet.has(e.to_entity.toLowerCase())) { untrackedSeen.add(e.to_entity); } } } const nodes = [ ...allTracked .filter(c => trackedIds.has(c.id)) .map(c => ({ id: c.id, name: c.name, ticker: c.ticker, tracked: true })), ...[...untrackedSeen].map(name => ({ id: null, name, ticker: null, tracked: false })), ]; return { nodes, edges }; }); // per-company facts for the graph sidebar fastify.get('/admin/api/intelligence/facts/:company_id', async (request, reply) => { if (!checkAuth(request, reply)) return; const idb = getIntelligenceDb(); if (!idb) return []; const companyId = parseInt(request.params.company_id, 10); return idb.prepare(` SELECT type, claim, confidence, confirmation_count FROM company_facts WHERE company_id = ? ORDER BY confirmation_count DESC LIMIT 10 `).all(companyId); }); // raw sql console — supports multiple statements separated by ; fastify.post('/admin/api/sql', async (request, reply) => { if (!checkAuth(request, reply)) return; const { sql, database } = request.body || {}; if (!sql || !sql.trim()) { reply.code(400); return { error: 'no sql provided' }; } const target = database === 'intelligence' ? getIntelligenceDb() : db; if (!target) { reply.code(400); return { error: 'database not available' }; } // split on semicolons, drop empty statements const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0); const results = []; const start = Date.now(); for (const s of statements) { try { const stmt = target.prepare(s); if (stmt.reader) { results.push({ sql: s, rows: stmt.all() }); } else { const info = stmt.run(); results.push({ sql: s, changes: info.changes, lastInsertRowid: info.lastInsertRowid }); } } catch (err) { results.push({ sql: s, error: err.message }); } } return { results, elapsed: Date.now() - start }; }); // stats for dashboard header fastify.get('/admin/api/stats', async (request, reply) => { if (!checkAuth(request, reply)) return; const total = db.prepare(`SELECT COUNT(*) as n FROM articles`).get().n; const withContent = db.prepare(`SELECT COUNT(*) as n FROM articles WHERE content IS NOT NULL AND content != ''`).get().n; const withEmbedding = db.prepare(`SELECT COUNT(*) as n FROM articles WHERE has_embedding = 1`).get().n; const eventCount = db.prepare(`SELECT COUNT(*) as n FROM events`).get().n; const bySource = db.prepare(` SELECT source, COUNT(*) as n FROM articles GROUP BY source ORDER BY n DESC `).all(); const byStatus = db.prepare(` SELECT COALESCE(content_status, 'null') as status, COUNT(*) as n FROM articles GROUP BY content_status ORDER BY n DESC `).all(); return { total, withContent, withEmbedding, eventCount, bySource, byStatus }; }); } module.exports = adminRoutes;