navidocs/server/routes/search.js
ggq-admin 155a8c0305 feat: NaviDocs MVP - Complete codebase extraction from lilian1
## Backend (server/)
- Express 5 API with security middleware (helmet, rate limiting)
- SQLite database with WAL mode (schema from docs/architecture/)
- Meilisearch integration with tenant tokens
- BullMQ + Redis background job queue
- OCR pipeline with Tesseract.js
- File safety validation (extension, MIME, size)
- 4 API route modules: upload, jobs, search, documents

## Frontend (client/)
- Vue 3 with Composition API (<script setup>)
- Vite 5 build system with HMR
- Tailwind CSS (Meilisearch-inspired design)
- UploadModal with drag-and-drop
- FigureZoom component (ported from lilian1)
- Meilisearch search integration with tenant tokens
- Job polling composable
- Clean SVG icons (no emojis)

## Code Extraction
-  manuals.js → UploadModal.vue, useJobPolling.js
-  figure-zoom.js → FigureZoom.vue
-  service-worker.js → client/public/service-worker.js (TODO)
-  glossary.json → Merged into Meilisearch synonyms
-  Discarded: quiz.js, persona.js, gamification.js (Frank-AI junk)

## Documentation
- Complete extraction plan in docs/analysis/
- README with quick start guide
- Architecture summary in docs/architecture/

## Build Status
- Server dependencies:  Installed (234 packages)
- Client dependencies:  Installed (160 packages)
- Client build:  Successful (2.63s)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 01:55:44 +02:00

180 lines
4.8 KiB
JavaScript

/**
* Search Route - POST /api/search
* Generate Meilisearch tenant tokens for client-side search
*/
import express from 'express';
import { getMeilisearchClient, generateTenantToken } from '../config/meilisearch.js';
import { getDb } from '../db/db.js';
const router = express.Router();
const INDEX_NAME = process.env.MEILISEARCH_INDEX_NAME || 'navidocs-pages';
/**
* POST /api/search/token
* Generate Meilisearch tenant token for client-side search
*
* @body {number} [expiresIn] - Token expiration in seconds (default: 3600 = 1 hour)
* @returns {Object} { token, expiresAt, indexName }
*/
router.post('/token', async (req, res) => {
try {
// TODO: Authentication middleware should provide req.user
const userId = req.user?.id || 'test-user-id';
const { expiresIn = 3600 } = req.body; // Default 1 hour
// Validate expiresIn
const maxExpiry = 86400; // 24 hours max
const tokenExpiry = Math.min(parseInt(expiresIn) || 3600, maxExpiry);
const db = getDb();
// Get user's organizations
const orgs = db.prepare(`
SELECT organization_id
FROM user_organizations
WHERE user_id = ?
`).all(userId);
const organizationIds = orgs.map(org => org.organization_id);
if (organizationIds.length === 0) {
return res.status(403).json({
error: 'No organizations found for user'
});
}
// Generate tenant token with user and organization filters
const token = generateTenantToken(userId, organizationIds, tokenExpiry);
const expiresAt = new Date(Date.now() + tokenExpiry * 1000);
res.json({
token,
expiresAt: expiresAt.toISOString(),
expiresIn: tokenExpiry,
indexName: INDEX_NAME,
searchUrl: process.env.MEILISEARCH_HOST || 'http://127.0.0.1:7700'
});
} catch (error) {
console.error('Token generation error:', error);
res.status(500).json({
error: 'Failed to generate search token',
message: error.message
});
}
});
/**
* POST /api/search
* Server-side search endpoint (optional, for server-rendered results)
*
* @body {string} q - Search query
* @body {Object} [filters] - Filter options
* @body {number} [limit] - Results limit (default: 20)
* @body {number} [offset] - Results offset (default: 0)
* @returns {Object} { hits, estimatedTotalHits, query, processingTimeMs }
*/
router.post('/', async (req, res) => {
try {
const { q, filters = {}, limit = 20, offset = 0 } = req.body;
if (!q || typeof q !== 'string') {
return res.status(400).json({ error: 'Query parameter "q" is required' });
}
// TODO: Authentication middleware should provide req.user
const userId = req.user?.id || 'test-user-id';
const db = getDb();
// Get user's organizations
const orgs = db.prepare(`
SELECT organization_id
FROM user_organizations
WHERE user_id = ?
`).all(userId);
const organizationIds = orgs.map(org => org.organization_id);
if (organizationIds.length === 0) {
return res.status(403).json({
error: 'No organizations found for user'
});
}
// Build Meilisearch filter
const filterParts = [
`userId = "${userId}" OR organizationId IN [${organizationIds.map(id => `"${id}"`).join(', ')}]`
];
// Add additional filters
if (filters.documentType) {
filterParts.push(`documentType = "${filters.documentType}"`);
}
if (filters.entityId) {
filterParts.push(`entityId = "${filters.entityId}"`);
}
if (filters.language) {
filterParts.push(`language = "${filters.language}"`);
}
const filterString = filterParts.join(' AND ');
// Get Meilisearch client and search
const client = getMeilisearchClient();
const index = client.index(INDEX_NAME);
const searchResults = await index.search(q, {
filter: filterString,
limit: parseInt(limit),
offset: parseInt(offset),
attributesToHighlight: ['text'],
attributesToCrop: ['text'],
cropLength: 200
});
res.json({
hits: searchResults.hits,
estimatedTotalHits: searchResults.estimatedTotalHits,
query: searchResults.query,
processingTimeMs: searchResults.processingTimeMs,
limit: parseInt(limit),
offset: parseInt(offset)
});
} catch (error) {
console.error('Search error:', error);
res.status(500).json({
error: 'Search failed',
message: error.message
});
}
});
/**
* GET /api/search/health
* Check Meilisearch health status
*/
router.get('/health', async (req, res) => {
try {
const client = getMeilisearchClient();
const health = await client.health();
res.json({
status: 'ok',
meilisearch: health
});
} catch (error) {
res.status(503).json({
status: 'error',
error: 'Meilisearch unavailable',
message: error.message
});
}
});
export default router;