import { storage } from "../storage"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { type ReminderRecipient } from "@shared/schema"; import { handleMcpRequest, handleMcpSse, handleMcpMessages } from "../mcp"; import { setupSwagger } from "../swagger"; import express, { type Request, type Response, type NextFunction } from 'express'; // In-process dedup: avoid spamming admins with repeated legacy-key notifications. // Key: "orgId:keyPrefix" — reset only on process restart. const _legacyKeyNotifiedSet = new Set(); async function notifyAdminsLegacyKey(organizationId: number, keyPrefix: string, label: string): Promise { const dedupeKey = `${organizationId}:${keyPrefix}`; if (_legacyKeyNotifiedSet.has(dedupeKey)) return; _legacyKeyNotifiedSet.add(dedupeKey); try { const admins = await storage.getUsersByRole('admin', organizationId); for (const admin of admins) { await storage.createUserNotification({ userId: admin.id, organizationId, type: 'system', title: 'Устаревший API-ключ', message: `API-ключ "${label}" (${keyPrefix}…) использует устаревший алгоритм (SHA-256). Удалите его и создайте новый в Настройки → API-ключи.`, isRead: false, }); } } catch { _legacyKeyNotifiedSet.delete(dedupeKey); } } async function requireMcpApiKey(req: Request, res: Response, next: NextFunction): Promise { const rawKey = (req.headers['x-api-key'] as string | undefined) || (req.headers['authorization'] as string | undefined)?.replace(/^Bearer\s+/i, '') || (req.query?.['apiKey'] as string | undefined) || (req.query?.['key'] as string | undefined); if (!rawKey) { res.status(401).json({ error: 'Missing API key. Provide Authorization: Bearer , X-Api-Key header, or ?key= query parameter.' }); return; } const trimmed = rawKey.trim(); const apiKey = await storage.getApiKeyByHash(trimmed); if (apiKey && apiKey.isActive) { storage.touchApiKey(apiKey.id).catch(() => {}); next(); return; } const legacyKey = await storage.getApiKeyByLegacyHash(trimmed); if (legacyKey && legacyKey.isActive) { notifyAdminsLegacyKey(legacyKey.organizationId, legacyKey.keyPrefix, legacyKey.label).catch(() => {}); res.status(401).json({ error: 'API key is outdated (SHA-256). Please delete it and generate a new one in Settings → API Keys.', }); return; } res.status(401).json({ error: 'Invalid or revoked API key.' }); } export function registerMcpRagRoutes(app: import("express").Express): void { /** * @swagger * /api/mcp-keys: * get: * tags: [MCP] * summary: Список API-ключей для MCP (admin) * description: API-ключи для подключения AI-агентов (Claude Desktop, Cursor, Cline). Требуется роль admin. * responses: * 200: * description: Список ключей (без keyHash) * post: * tags: [MCP] * summary: Создать API-ключ (admin) * description: Ключ показывается **только один раз** в поле `key`. Формат — `wf_`. * /api/mcp-keys/{id}: * delete: * tags: [MCP] * summary: Удалить API-ключ (admin) */ app.get('/api/mcp-keys', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const keys = await storage.listApiKeys(req.organizationId!); const safeKeys = keys.map(({ keyHash: _h, ...rest }) => rest); res.json({ success: true, keys: safeKeys }); } catch (error) { console.error('List MCP keys error:', error); res.status(500).json({ error: 'Ошибка получения ключей' }); } }); app.post('/api/mcp-keys', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const label = (req.body?.label as string | undefined)?.trim() || 'Default'; const { key, record } = await storage.createApiKey(req.organizationId!, req.user!.id, label); const { keyHash: _h, ...safeRecord } = record; res.json({ success: true, key, record: safeRecord }); } catch (error) { console.error('Create MCP key error:', error); res.status(500).json({ error: 'Ошибка создания ключа' }); } }); app.delete('/api/mcp-keys/:id', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' }); await storage.deleteApiKey(id, req.organizationId!); res.json({ success: true }); } catch (error) { console.error('Delete MCP key error:', error); res.status(500).json({ error: 'Ошибка удаления ключа' }); } }); // RAG / Semantic Search Endpoints app.get('/api/mcp/rag-stats', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const { getEmbeddingCounts } = await import('../services/embedding.service'); const rawCounts = await getEmbeddingCounts(req.organizationId!); const counts = { form: rawCounts['form'] ?? 0, task: rawCounts['task'] ?? 0, task_message: rawCounts['task_message'] ?? 0, }; res.json({ success: true, counts }); } catch (error) { console.error('RAG stats error:', error); res.status(500).json({ success: false, error: 'Ошибка получения статистики индекса' }); } }); interface ReindexProgress { indexed: number; total: number; attempted: number; failed: number; status: 'running' | 'done' | 'error'; startedAt: number; result?: { forms: number; tasks: number; messages: number; attempted: number; failed: number }; error?: string; } const reindexProgressStore = new Map(); app.get('/api/mcp/reindex-progress', authenticateToken, tenantIsolation, requirePermission('settings.manage'), (req: AuthenticatedRequest, res) => { const organizationId = req.organizationId!; const progress = reindexProgressStore.get(organizationId); if (!progress) { return res.json({ success: true, status: 'idle' }); } res.json({ success: true, ...progress }); }); app.post('/api/mcp/reindex', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const organizationId = req.organizationId!; const existing = reindexProgressStore.get(organizationId); if (existing && existing.status === 'running') { return res.json({ success: true, message: 'Переиндексация уже выполняется', alreadyRunning: true }); } const rawTypes: string[] = req.body?.entity_types ?? ['form', 'task', 'task_message']; const validTypes = ['form', 'task', 'task_message']; const entityTypes = rawTypes.filter(t => validTypes.includes(t)) as import('../services/embedding.service').EntityType[]; const { reindexOrganization } = await import('../services/embedding.service'); reindexProgressStore.set(organizationId, { indexed: 0, total: 0, attempted: 0, failed: 0, status: 'running', startedAt: Date.now() }); reindexOrganization(storage, organizationId, entityTypes, (indexed, total) => { const prev = reindexProgressStore.get(organizationId); reindexProgressStore.set(organizationId, { indexed, total, attempted: prev?.attempted ?? 0, failed: prev?.failed ?? 0, status: 'running', startedAt: prev?.startedAt ?? Date.now(), }); }).then((result) => { const finalTotal = result.attempted; reindexProgressStore.set(organizationId, { indexed: result.forms + result.tasks + result.messages, total: finalTotal, attempted: result.attempted, failed: result.failed, status: 'done', startedAt: reindexProgressStore.get(organizationId)?.startedAt ?? Date.now(), result, }); setTimeout(() => reindexProgressStore.delete(organizationId), 5 * 60 * 1000); }).catch((err) => { console.error('[RAG] Reindex error:', err); const prev = reindexProgressStore.get(organizationId); reindexProgressStore.set(organizationId, { indexed: prev?.indexed ?? 0, total: prev?.total ?? 0, attempted: prev?.attempted ?? 0, failed: prev?.failed ?? 0, status: 'error', startedAt: prev?.startedAt ?? Date.now(), error: String(err), }); setTimeout(() => reindexProgressStore.delete(organizationId), 5 * 60 * 1000); }); res.json({ success: true, message: 'Переиндексация запущена в фоне' }); } catch (error) { console.error('Reindex error:', error); res.status(500).json({ success: false, error: 'Ошибка запуска переиндексации' }); } }); app.patch('/api/forms/:id/ai-summary', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.id); if (isNaN(formId)) return res.status(400).json({ success: false, error: 'Неверный ID формы' }); const form = await storage.getForm(formId, req.organizationId!); if (!form) return res.status(404).json({ success: false, error: 'Форма не найдена' }); const { aiSummary, aiSummaryIsAuto } = req.body; if (aiSummaryIsAuto !== undefined && typeof aiSummaryIsAuto !== 'boolean') { return res.status(400).json({ success: false, error: 'aiSummaryIsAuto должен быть булевым значением' }); } const updated = await storage.updateForm(formId, req.organizationId!, { aiSummary, ...(aiSummaryIsAuto !== undefined ? { aiSummaryIsAuto } : {}), }); const { buildFormText, upsertEmbedding } = await import('../services/embedding.service'); const [fields, statuses] = await Promise.all([ storage.getFormFields(formId, req.organizationId!), storage.getFormStatuses(formId, req.organizationId!), ]); const content = buildFormText(updated, fields, statuses); upsertEmbedding(req.organizationId!, 'form', formId, content, { name: updated.name }).catch(() => {}); res.json({ success: true, form: updated }); } catch (error) { console.error('Update AI summary error:', error); res.status(500).json({ success: false, error: 'Ошибка обновления резюме' }); } }); app.post('/api/forms/:id/regenerate-summary', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.id); if (isNaN(formId)) return res.status(400).json({ success: false, error: 'Неверный ID формы' }); const form = await storage.getForm(formId, req.organizationId!); if (!form) return res.status(404).json({ success: false, error: 'Форма не найдена' }); const { generateFormSummary } = await import('../services/embedding.service'); const [fields, statuses] = await Promise.all([ storage.getFormFields(formId, req.organizationId!), storage.getFormStatuses(formId, req.organizationId!), ]); const summary = await generateFormSummary(form, fields, statuses, req.organizationId!); res.json({ success: true, aiSummary: summary ?? null }); } catch (error) { console.error('Regenerate summary error:', error); res.status(500).json({ success: false, error: 'Ошибка генерации резюме' }); } }); // Task Reminders Endpoints app.get('/api/tasks/:id/reminders', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const taskId = parseInt(req.params.id); if (isNaN(taskId)) return res.status(400).json({ success: false, error: 'Неверный ID задачи' }); try { const reminders = await storage.getTaskReminders(taskId, req.organizationId!); return res.json({ success: true, reminders }); } catch (err: any) { return res.status(500).json({ success: false, error: err.message }); } }); app.post('/api/tasks/:id/reminders', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const taskId = parseInt(req.params.id); if (isNaN(taskId)) return res.status(400).json({ success: false, error: 'Неверный ID задачи' }); const task = await storage.getTask(taskId, req.organizationId!); if (!task) return res.status(404).json({ success: false, error: 'Задача не найдена' }); const { remindAt, note, recipients } = req.body; if (!remindAt) return res.status(400).json({ success: false, error: 'Укажите дату и время напоминания' }); const remindAtDate = new Date(remindAt); if (isNaN(remindAtDate.getTime())) { return res.status(400).json({ success: false, error: 'Неверный формат даты напоминания' }); } if (!Array.isArray(recipients) || recipients.length === 0) { return res.status(400).json({ success: false, error: 'Укажите хотя бы одного получателя' }); } const ALLOWED_SYSTEM_ROLES = new Set(['admin', 'user']); const validatedRecipients: ReminderRecipient[] = []; const hasUserRecipients = recipients.some((rec: any) => rec.type === 'user'); const orgUsersMap = hasUserRecipients ? new Map((await storage.getUsersByOrganization(req.organizationId!)).map(u => [u.id, u])) : new Map(); for (const rec of recipients) { if (rec.type === 'user') { if (typeof rec.userId !== 'number') return res.status(400).json({ success: false, error: 'Неверный формат получателя' }); const orgUser = orgUsersMap.get(rec.userId); if (!orgUser) { return res.status(400).json({ success: false, error: `Пользователь ${rec.userId} не принадлежит организации` }); } validatedRecipients.push({ type: 'user', userId: rec.userId }); } else if (rec.type === 'role') { if (typeof rec.role !== 'string' || !ALLOWED_SYSTEM_ROLES.has(rec.role)) { return res.status(400).json({ success: false, error: `Недопустимая системная роль: ${rec.role}` }); } validatedRecipients.push({ type: 'role', role: rec.role }); } else if (rec.type === 'orgRole') { if (typeof rec.roleId !== 'number') return res.status(400).json({ success: false, error: 'Неверный формат роли организации' }); await storage.getUsersByOrgRoleId(rec.roleId, req.organizationId!); validatedRecipients.push({ type: 'orgRole', roleId: rec.roleId }); } else { return res.status(400).json({ success: false, error: 'Неверный тип получателя' }); } } try { const reminder = await storage.createTaskReminder({ taskId, organizationId: req.organizationId!, createdByUserId: req.user!.id, remindAt: remindAtDate, note: note || null, recipients: validatedRecipients, }); return res.json({ success: true, reminder }); } catch (err: any) { return res.status(500).json({ success: false, error: err.message }); } }); app.delete('/api/reminders/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const id = parseInt(req.params.id); if (isNaN(id)) return res.status(400).json({ success: false, error: 'Неверный ID' }); try { await storage.deleteTaskReminder(id, req.organizationId!); return res.json({ success: true }); } catch (err: any) { return res.status(500).json({ success: false, error: err.message }); } }); // MCP Server Endpoints — require valid org API key at route level // Handle notifications before handleMcpRequest to avoid transport issues with consumed body streams. // NOTE: we rely on the global express.json() middleware (already applied in server/index.ts). app.all('/mcp', (req, res, next) => { if ( req.method === 'POST' && req.body && !Array.isArray(req.body) && typeof (req.body as any).method === 'string' && (req.body as any).method.startsWith('notifications/') ) { return res.status(202).send(); } next(); }, handleMcpRequest); app.get('/mcp/sse', requireMcpApiKey, handleMcpSse); app.post('/mcp/messages', requireMcpApiKey, express.json(), handleMcpMessages); // Swagger UI setupSwagger(app); }