## 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>
180 lines
4.8 KiB
JavaScript
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;
|