import express from 'express'; import crypto from 'crypto'; import { storage } from "../storage"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; export function registerExternalRoutes(app: import("express").Express): void { // ===================================================== // EXTERNAL SERVICES API // ===================================================== // Get all external services for organization app.get('/api/external-services', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const services = await storage.getExternalServices(req.organizationId!); // Mask API keys for security (only show last 4 characters) const maskedServices = services.map(s => ({ ...s, apiKey: s.apiKey ? `***${s.apiKey.slice(-4)}` : null, secretKey: s.secretKey ? `***${s.secretKey.slice(-4)}` : null, })); res.json({ success: true, services: maskedServices }); } catch (error) { console.error('Get external services error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении сервисов' }); } } ); // Get single external service app.get('/api/external-services/:id', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); if (isNaN(id)) { return res.status(400).json({ success: false, error: 'Некорректный ID' }); } const service = await storage.getExternalService(id, req.organizationId!); if (!service) { return res.status(404).json({ success: false, error: 'Сервис не найден' }); } // Mask API keys const maskedService = { ...service, apiKey: service.apiKey ? `***${service.apiKey.slice(-4)}` : null, secretKey: service.secretKey ? `***${service.secretKey.slice(-4)}` : null, }; res.json({ success: true, service: maskedService }); } catch (error) { console.error('Get external service error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении сервиса' }); } } ); // Create external service app.post('/api/external-services', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { serviceType, serviceName, apiKey, secretKey, config } = req.body; if (!serviceType || !serviceName) { return res.status(400).json({ success: false, error: 'Тип и название сервиса обязательны' }); } // Encrypt API keys before storing const { encrypt } = await import('../crypto'); const service = await storage.createExternalService({ organizationId: req.organizationId!, serviceType, serviceName, apiKey: apiKey ? encrypt(apiKey) : null, secretKey: secretKey ? encrypt(secretKey) : null, config: config || null, isActive: true, }); res.status(201).json({ success: true, service: { ...service, apiKey: '***', secretKey: '***' } }); } catch (error: unknown) { console.error('Create external service error:', error); if ((error as { code?: string }).code === '23505') { return res.status(400).json({ success: false, error: 'Сервис этого типа уже существует' }); } res.status(500).json({ success: false, error: 'Ошибка при создании сервиса' }); } } ); // Update external service app.put('/api/external-services/:id', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); if (isNaN(id)) { return res.status(400).json({ success: false, error: 'Некорректный ID' }); } const existing = await storage.getExternalService(id, req.organizationId!); if (!existing) { return res.status(404).json({ success: false, error: 'Сервис не найден' }); } const { serviceName, apiKey, secretKey, config, isActive } = req.body; const { encrypt } = await import('../crypto'); const updates: Record = {}; if (serviceName !== undefined) updates.serviceName = serviceName; if (apiKey !== undefined) updates.apiKey = apiKey ? encrypt(apiKey) : null; if (secretKey !== undefined) updates.secretKey = secretKey ? encrypt(secretKey) : null; if (config !== undefined) updates.config = config; if (isActive !== undefined) updates.isActive = isActive; const updated = await storage.updateExternalService(id, req.organizationId!, updates); res.json({ success: true, service: { ...updated, apiKey: '***', secretKey: '***' } }); } catch (error) { console.error('Update external service error:', error); res.status(500).json({ success: false, error: 'Ошибка при обновлении сервиса' }); } } ); // Delete external service app.delete('/api/external-services/:id', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); if (isNaN(id)) { return res.status(400).json({ success: false, error: 'Некорректный ID' }); } const existing = await storage.getExternalService(id, req.organizationId!); if (!existing) { return res.status(404).json({ success: false, error: 'Сервис не найден' }); } await storage.deleteExternalService(id, req.organizationId!); res.json({ success: true, message: 'Сервис удален' }); } catch (error) { console.error('Delete external service error:', error); res.status(500).json({ success: false, error: 'Ошибка при удалении сервиса' }); } } ); // ===================================================== // DADATA API PROXY // ===================================================== // Proxy for Dadata suggest/party app.post('/api/dadata/suggest/party', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { query, count = 10 } = req.body; if (!query) { return res.status(400).json({ success: false, error: 'Запрос обязателен' }); } // First try to get org-specific Dadata key const orgService = await storage.getExternalServiceByType('dadata', req.organizationId!); let apiKey: string | null = null; if (orgService && orgService.apiKey) { const { decrypt } = await import('../crypto'); apiKey = decrypt(orgService.apiKey); } else { // Fallback to global Dadata key apiKey = process.env.DADATA_API_KEY || null; } if (!apiKey) { return res.status(400).json({ success: false, error: 'API-ключ Dadata не настроен. Добавьте его в разделе "Доступы к сервисам".' }); } const response = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/party', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Token ${apiKey}`, }, body: JSON.stringify({ query, count: Math.min(count, 20) }), }); if (!response.ok) { const errorText = await response.text(); console.error('Dadata API error:', errorText); return res.status(response.status).json({ success: false, error: 'Ошибка API Dadata' }); } const data = await response.json(); res.json({ success: true, suggestions: data.suggestions }); } catch (error) { console.error('Dadata proxy error:', error); res.status(500).json({ success: false, error: 'Ошибка при запросе к Dadata' }); } } ); // Proxy for Dadata suggest/address (address suggestions + coordinates) app.post('/api/dadata/suggest/address', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { query, count = 10 } = req.body; if (!query) { return res.status(400).json({ success: false, error: 'Запрос обязателен' }); } const orgService = await storage.getExternalServiceByType('dadata', req.organizationId!); let apiKey: string | null = null; if (orgService && orgService.apiKey) { const { decrypt } = await import('../crypto'); apiKey = decrypt(orgService.apiKey); } else { apiKey = process.env.DADATA_API_KEY || null; } if (!apiKey) { return res.status(400).json({ success: false, error: 'API-ключ Dadata не настроен. Добавьте его в разделе "Доступы к сервисам".' }); } const response = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Token ${apiKey}`, }, body: JSON.stringify({ query, count: Math.min(count, 20) }), }); if (!response.ok) { const errorText = await response.text(); console.error('Dadata address API error:', errorText); return res.status(response.status).json({ success: false, error: 'Ошибка API Dadata' }); } const data = await response.json(); res.json({ success: true, suggestions: data.suggestions }); } catch (error) { console.error('Dadata address proxy error:', error); res.status(500).json({ success: false, error: 'Ошибка при запросе к Dadata' }); } } ); // Proxy for Dadata geolocate (reverse geocoding: lat/lon -> address) app.post('/api/dadata/geolocate', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { lat, lon } = req.body; if (lat === undefined || lon === undefined) { return res.status(400).json({ success: false, error: 'Широта и долгота обязательны' }); } const orgService = await storage.getExternalServiceByType('dadata', req.organizationId!); let apiKey: string | null = null; if (orgService && orgService.apiKey) { const { decrypt } = await import('../crypto'); apiKey = decrypt(orgService.apiKey); } else { apiKey = process.env.DADATA_API_KEY || null; } if (!apiKey) { return res.status(400).json({ success: false, error: 'API-ключ Dadata не настроен. Добавьте его в разделе "Доступы к сервисам".' }); } const response = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/geolocate/address', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Token ${apiKey}`, }, body: JSON.stringify({ lat, lon }), }); if (!response.ok) { const errorText = await response.text(); console.error('Dadata geolocate API error:', errorText); return res.status(response.status).json({ success: false, error: 'Ошибка API Dadata' }); } const data = await response.json(); res.json({ success: true, suggestions: data.suggestions }); } catch (error) { console.error('Dadata geolocate proxy error:', error); res.status(500).json({ success: false, error: 'Ошибка при запросе к Dadata' }); } } ); // ===================================================== // UNIVERSAL GEO API (OpenStreetMap / DaData) // ===================================================== // In-memory rate limiter for OpenStreetMap (1 request per second globally) let lastOsmRequestTime = 0; const OSM_MIN_INTERVAL_MS = 1000; async function osmFetch(url: string): Promise { const now = Date.now(); const wait = lastOsmRequestTime + OSM_MIN_INTERVAL_MS - now; if (wait > 0) { await new Promise(resolve => setTimeout(resolve, wait)); } lastOsmRequestTime = Date.now(); return fetch(url, { headers: { 'User-Agent': 'iistwin (iistwin.ru)', 'Accept': 'application/json', }, }); } function nominatimToDaDataFormat(item: { display_name: string; lat: string; lon: string }) { return { value: item.display_name, unrestricted_value: item.display_name, data: { geo_lat: item.lat, geo_lon: item.lon, }, }; } async function getGeoProvider(organizationId: number): Promise<{ type: 'osm' } | { type: 'dadata'; apiKey: string }> { const osmService = await storage.getExternalServiceByType('openstreetmap', organizationId); if (osmService?.isActive) { return { type: 'osm' }; } const dadataService = await storage.getExternalServiceByType('dadata', organizationId); let apiKey: string | null = null; if (dadataService && dadataService.apiKey) { const { decrypt } = await import('../crypto'); apiKey = decrypt(dadataService.apiKey); } else { apiKey = process.env.DADATA_API_KEY || null; } if (!apiKey) { throw new Error('API-ключ DaData не настроен. Добавьте его в разделе "Доступы к сервисам".'); } return { type: 'dadata', apiKey }; } // Universal address suggest endpoint app.post('/api/geo/suggest', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { query, count = 10 } = req.body; if (!query) { return res.status(400).json({ success: false, error: 'Запрос обязателен' }); } const provider = await getGeoProvider(req.organizationId!); if (provider.type === 'osm') { const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=${Math.min(count, 20)}`; const response = await osmFetch(url); if (!response.ok) { const errorText = await response.text(); console.error('Nominatim search error:', errorText); return res.status(response.status).json({ success: false, error: 'Ошибка API OpenStreetMap' }); } const data = await response.json() as Array<{ display_name: string; lat: string; lon: string }>; const suggestions = (data || []).map(nominatimToDaDataFormat); return res.json({ success: true, suggestions }); } // DaData fallback const response = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Token ${provider.apiKey}`, }, body: JSON.stringify({ query, count: Math.min(count, 20) }), }); if (!response.ok) { const errorText = await response.text(); console.error('Dadata address API error:', errorText); return res.status(response.status).json({ success: false, error: 'Ошибка API Dadata' }); } const data = await response.json(); res.json({ success: true, suggestions: data.suggestions }); } catch (error: unknown) { console.error('Geo suggest error:', error); const message = error instanceof Error ? error.message : 'Ошибка при запросе геокодинга'; res.status(500).json({ success: false, error: message }); } } ); // Universal reverse geocoding endpoint app.post('/api/geo/reverse', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { lat, lon } = req.body; if (lat === undefined || lon === undefined) { return res.status(400).json({ success: false, error: 'Широта и долгота обязательны' }); } const provider = await getGeoProvider(req.organizationId!); if (provider.type === 'osm') { const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`; const response = await osmFetch(url); if (!response.ok) { const errorText = await response.text(); console.error('Nominatim reverse error:', errorText); return res.status(response.status).json({ success: false, error: 'Ошибка API OpenStreetMap' }); } const data = await response.json() as { display_name?: string; lat?: string; lon?: string }; if (data && data.display_name) { const suggestion = nominatimToDaDataFormat({ display_name: data.display_name, lat: String(data.lat ?? lat), lon: String(data.lon ?? lon), }); return res.json({ success: true, suggestions: [suggestion] }); } return res.json({ success: true, suggestions: [] }); } // DaData fallback const response = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/geolocate/address', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Token ${provider.apiKey}`, }, body: JSON.stringify({ lat, lon }), }); if (!response.ok) { const errorText = await response.text(); console.error('Dadata geolocate API error:', errorText); return res.status(response.status).json({ success: false, error: 'Ошибка API Dadata' }); } const data = await response.json(); res.json({ success: true, suggestions: data.suggestions }); } catch (error: unknown) { console.error('Geo reverse error:', error); const message = error instanceof Error ? error.message : 'Ошибка при запросе геокодинга'; res.status(500).json({ success: false, error: message }); } } ); // ===================================================== // REGULAR TABLE ROWS API (for type='table' tabs) // ===================================================== // Get all rows for a regular table tab app.get('/api/tasks/:taskId/regular-table/:tabId/rows', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.taskId); const tabId = parseInt(req.params.tabId); if (isNaN(taskId) || isNaN(tabId)) { return res.status(400).json({ success: false, error: 'Некорректные параметры' }); } // Verify task exists and belongs to organization const task = await storage.getTask(taskId, req.organizationId!); if (!task) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } const rows = await storage.getRegularTableRows(taskId, tabId); res.json({ success: true, rows }); } catch (error) { console.error('Get regular table rows error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении строк таблицы' }); } } ); // Create a new row in a regular table app.post('/api/tasks/:taskId/regular-table/:tabId/rows', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.taskId); const tabId = parseInt(req.params.tabId); if (isNaN(taskId) || isNaN(tabId)) { return res.status(400).json({ success: false, error: 'Некорректные параметры' }); } // Verify task exists and belongs to organization const task = await storage.getTask(taskId, req.organizationId!); if (!task) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } if (!req.user?.id) { return res.status(401).json({ success: false, error: 'Пользователь не авторизован' }); } const { data, position } = req.body; const row = await storage.createRegularTableRow({ taskId, tabId, data: data || {}, position, createdBy: req.user.id, }); // Audit log: row added (use persisted row.data to reflect actual stored state) const tab = await storage.getFormTab(tabId, task.formId, req.organizationId!); const editorName = `${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email || 'API'; const persistedData = row.data && typeof row.data === 'object' && Object.keys(row.data).length > 0 ? row.data : null; storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'field.changed', fieldName: `Таблица «${tab?.name || tabId}»: добавлена строка`, oldValue: null, newValue: persistedData, changedBy: req.user.id, changedByName: editorName, }).catch((e: unknown) => { console.error('Audit log error (table row add):', e); }); res.json({ success: true, row }); } catch (error) { console.error('Create regular table row error:', error); res.status(500).json({ success: false, error: 'Ошибка при создании строки' }); } } ); // Update a row in a regular table app.patch('/api/tasks/:taskId/regular-table/:tabId/rows/:rowId', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.taskId); const tabId = parseInt(req.params.tabId); const rowId = parseInt(req.params.rowId); if (isNaN(taskId) || isNaN(tabId) || isNaN(rowId)) { return res.status(400).json({ success: false, error: 'Некорректные параметры' }); } // Verify task exists and belongs to organization const task = await storage.getTask(taskId, req.organizationId!); if (!task) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } if (!req.user?.id) { return res.status(401).json({ success: false, error: 'Пользователь не авторизован' }); } const { data, position } = req.body; // Fetch old row data for audit log const oldRow = await storage.getRegularTableRow(rowId, taskId, tabId); const updates: Record = { updatedBy: req.user.id }; if (data !== undefined) updates.data = data; if (position !== undefined) updates.position = position; const row = await storage.updateRegularTableRow(rowId, taskId, tabId, updates); // Audit log: per-column diff (only when data changed, not just position) if (data !== undefined && typeof data === 'object' && data !== null) { const tab = await storage.getFormTab(tabId, task.formId, req.organizationId!); const editorName = `${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email || 'API'; const tabName = tab?.name || String(tabId); // Build colId → colName map from tab.tableColumns type ColDef = { id: string; name: string }; const colDefs = Array.isArray(tab?.tableColumns) ? (tab.tableColumns as ColDef[]) : []; const colNameMap = new Map(colDefs.map(c => [String(c.id), c.name || c.id])); const oldData = (oldRow?.data && typeof oldRow.data === 'object') ? oldRow.data as Record : {}; const newData = data as Record; // Emit one audit entry per changed column const auditPromises: Promise[] = []; for (const colId of Object.keys(newData)) { const oldVal = oldData[colId] ?? null; const newVal = newData[colId] ?? null; const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal); const newStr = newVal === null || newVal === undefined ? '' : String(newVal); if (oldStr !== newStr) { const colName = colNameMap.get(colId) || colId; auditPromises.push( storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'field.changed', fieldName: `Таблица «${tabName}» — ${colName}`, oldValue: oldVal, newValue: newVal, changedBy: req.user.id, changedByName: editorName, }).catch((e: unknown) => { console.error('Audit log error (table row update):', e); }) ); } } Promise.all(auditPromises).catch(() => {}); } res.json({ success: true, row }); } catch (error) { console.error('Update regular table row error:', error); res.status(500).json({ success: false, error: 'Ошибка при обновлении строки' }); } } ); // Delete a row from a regular table app.delete('/api/tasks/:taskId/regular-table/:tabId/rows/:rowId', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.taskId); const tabId = parseInt(req.params.tabId); const rowId = parseInt(req.params.rowId); if (isNaN(taskId) || isNaN(tabId) || isNaN(rowId)) { return res.status(400).json({ success: false, error: 'Некорректные параметры' }); } // Verify task exists and belongs to organization const task = await storage.getTask(taskId, req.organizationId!); if (!task) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } const hasAccess = await storage.canUserAccessTask( taskId, req.user!.id, req.organizationId!, req.user!.appRole ); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' }); } // Fetch row data before deletion for audit log const deletedRow = await storage.getRegularTableRow(rowId, taskId, tabId); if (!deletedRow) { return res.status(404).json({ success: false, error: 'Строка не найдена' }); } const canDeleteAny = await storage.hasAppRolePermission(req.user!.appRole, ['tasks.edit_all']); if (!canDeleteAny && deletedRow.createdBy !== req.user!.id) { return res.status(403).json({ success: false, error: 'Нельзя удалить чужую строку' }); } await storage.deleteRegularTableRow(rowId, taskId, tabId); // Audit log: row deleted if (req.user?.id) { const tab = await storage.getFormTab(tabId, task.formId, req.organizationId!); const editorName = `${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email || 'API'; storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'field.changed', fieldName: `Таблица «${tab?.name || tabId}»: удалена строка`, oldValue: deletedRow?.data ?? null, newValue: null, changedBy: req.user.id, changedByName: editorName, }).catch((e: unknown) => { console.error('Audit log error (table row delete):', e); }); } res.json({ success: true, message: 'Строка удалена' }); } catch (error) { console.error('Delete regular table row error:', error); res.status(500).json({ success: false, error: 'Ошибка при удалении строки' }); } } ); }