Files
iistwin/server/routes/external.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

732 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown> = {};
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<Response> {
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<string, unknown> = { 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<string, string>(colDefs.map(c => [String(c.id), c.name || c.id]));
const oldData = (oldRow?.data && typeof oldRow.data === 'object') ? oldRow.data as Record<string, unknown> : {};
const newData = data as Record<string, unknown>;
// Emit one audit entry per changed column
const auditPromises: Promise<unknown>[] = [];
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: 'Ошибка при удалении строки' });
}
}
);
}