- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
732 lines
29 KiB
TypeScript
732 lines
29 KiB
TypeScript
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: 'Ошибка при удалении строки' });
|
||
}
|
||
}
|
||
);
|
||
|
||
}
|