Files
studyos/server/routes/search.js
renato97 4ff4302a8c feat: implement 33 nice-to-have features + fix 37 code review bugs
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)
2026-06-08 18:18:47 -03:00

109 lines
3.3 KiB
JavaScript

const express = require('express');
const db = require('../db');
const router = express.Router();
// GET /api/search?q=term
router.get('/', (req, res) => {
const q = req.query.q || '';
const term = q.trim();
if (!term) {
return res.json({ messages: [], pdfs: [] });
}
try {
const messageResults = [];
const pdfResults = [];
const fts5 = db._fts5Available;
// Sanitize term for FTS5: quote each whitespace-separated term
const safeTerm = term.split(/\s+/).filter(Boolean).map(t => `"${t.replace(/"/g, '""')}"`).join(' ');
const fts5Term = fts5 ? safeTerm : null;
if (fts5 && fts5Term) {
// FTS5 path: use MATCH with snippet highlighting and BM25 ranking
const messageRows = db.prepare(`
SELECT m.id, m.conversation_id, m.content,
snippet(messages_fts, 1, '<b>', '</b>', '…', 16) as snippet,
rank
FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
WHERE messages_fts MATCH ?
ORDER BY rank
LIMIT 25
`).all(fts5Term);
const pdfRows = db.prepare(`
SELECT p.id, p.original_name, p.content_markdown,
snippet(pdfs_fts, 1, '<b>', '</b>', '…', 16) as snippet,
rank
FROM pdfs_fts
JOIN pdfs p ON p.id = pdfs_fts.rowid
WHERE pdfs_fts MATCH ?
ORDER BY rank
LIMIT 25
`).all(fts5Term);
for (const row of messageRows) {
messageResults.push({
type: 'message',
id: row.id,
title: row.content?.slice(0, 60) || 'Mensaje',
snippet: row.snippet || '',
rank: row.rank || 0,
conversation_id: row.conversation_id,
});
}
for (const row of pdfRows) {
pdfResults.push({
type: 'pdf',
id: row.id,
title: row.original_name || 'PDF',
snippet: row.snippet || '',
rank: row.rank || 0,
});
}
} else {
// LIKE fallback (no ranking, no snippets)
const likeTerm = `%${term}%`;
const messageRows = db.prepare(
`SELECT id, conversation_id, content FROM messages WHERE content LIKE ? LIMIT 25`
).all(likeTerm);
const pdfRows = db.prepare(
`SELECT id, original_name, content_markdown FROM pdfs WHERE content_markdown LIKE ? LIMIT 25`
).all(likeTerm);
for (const row of messageRows) {
messageResults.push({
type: 'message',
id: row.id,
title: row.content?.slice(0, 60) || 'Mensaje',
snippet: row.content?.slice(0, 120) || '',
rank: 0,
conversation_id: row.conversation_id,
});
}
for (const row of pdfRows) {
pdfResults.push({
type: 'pdf',
id: row.id,
title: row.original_name || 'PDF',
snippet: row.content_markdown?.slice(0, 120) || '',
rank: 0,
});
}
}
// Return grouped results — keep message BM25 and PDF BM25 ranks separate (different indexes)
messageResults.sort((a, b) => (a.rank || 0) - (b.rank || 0));
pdfResults.sort((a, b) => (a.rank || 0) - (b.rank || 0));
res.json({ messages: messageResults.slice(0, 25), pdfs: pdfResults.slice(0, 25) });
} catch (err) {
console.error('[search] error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;