fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
618
server/routes/data-tables.routes.ts
Normal file
618
server/routes/data-tables.routes.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import { storage } from "../storage";
|
||||
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||
import { logAudit } from "../utils/audit";
|
||||
import {
|
||||
getDirectoryFolders,
|
||||
createDirectoryFolder,
|
||||
updateDirectoryFolder,
|
||||
deleteDirectoryFolder,
|
||||
moveDirectoryToFolder,
|
||||
reorderDirectoryFolders,
|
||||
} from "../storage/folder.storage";
|
||||
|
||||
export function registerDataTableRoutes(app: import("express").Express): void {
|
||||
// =====================================================
|
||||
// DATA TABLES API (Справочники / Directories)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/tables:
|
||||
* get:
|
||||
* tags: [Tables]
|
||||
* summary: Список справочников (data tables)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Справочники организации
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Table'
|
||||
* post:
|
||||
* tags: [Tables]
|
||||
* summary: Создать справочник (admin)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [name, code]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* code:
|
||||
* type: string
|
||||
* columns:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Справочник создан
|
||||
* /api/tables/{id}:
|
||||
* get:
|
||||
* tags: [Tables]
|
||||
* summary: Получить справочник
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Данные справочника
|
||||
* patch:
|
||||
* tags: [Tables]
|
||||
* summary: Обновить справочник
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* columns:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Обновлён
|
||||
* delete:
|
||||
* tags: [Tables]
|
||||
* summary: Удалить справочник (admin)
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Удалён
|
||||
* /api/tables/{tableId}/rows:
|
||||
* get:
|
||||
* tags: [Tables]
|
||||
* summary: Строки справочника
|
||||
* parameters:
|
||||
* - name: tableId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - name: search
|
||||
* in: query
|
||||
* schema:
|
||||
* type: string
|
||||
* - name: page
|
||||
* in: query
|
||||
* schema:
|
||||
* type: integer
|
||||
* - name: limit
|
||||
* in: query
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Строки
|
||||
* post:
|
||||
* tags: [Tables]
|
||||
* summary: Добавить строку
|
||||
* parameters:
|
||||
* - name: tableId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* description: Map columnCode → value
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Строка добавлена
|
||||
*/
|
||||
// Get all directories/tables accessible to current user
|
||||
app.get(['/api/tables', '/api/directories'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const tables = await storage.getDataTablesWithAccess(req.user!.id, req.organizationId!);
|
||||
res.json({ tables });
|
||||
} catch (error) {
|
||||
console.error('Get tables error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения списка таблиц' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// =====================================================
|
||||
// DIRECTORY FOLDERS (must be defined BEFORE /api/directories/:id)
|
||||
// =====================================================
|
||||
app.get('/api/directories/folders', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const folders = await getDirectoryFolders(req.organizationId!);
|
||||
res.json({ success: true, folders });
|
||||
} catch (error) {
|
||||
console.error('Get directory folders error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при получении папок' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/directories/folders', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { name, parentId, position } = req.body;
|
||||
if (!name || typeof name !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'Название папки обязательно' });
|
||||
}
|
||||
const folder = await createDirectoryFolder({
|
||||
organizationId: req.organizationId!,
|
||||
name,
|
||||
parentId: parentId ?? null,
|
||||
position: position ?? 0,
|
||||
});
|
||||
res.status(201).json({ success: true, folder });
|
||||
} catch (error) {
|
||||
console.error('Create directory folder error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при создании папки' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/directories/folders/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { name, parentId, position } = req.body;
|
||||
const folder = await updateDirectoryFolder(id, req.organizationId!, { name, parentId, position });
|
||||
if (!folder) {
|
||||
return res.status(404).json({ success: false, error: 'Папка не найдена' });
|
||||
}
|
||||
res.json({ success: true, folder });
|
||||
} catch (error) {
|
||||
console.error('Update directory folder error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при обновлении папки' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/directories/folders/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
await deleteDirectoryFolder(id, req.organizationId!);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete directory folder error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при удалении папки' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/directories/folders/reorder', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { updates } = req.body;
|
||||
if (!Array.isArray(updates)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректные данные' });
|
||||
}
|
||||
await reorderDirectoryFolders(req.organizationId!, updates);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Reorder directory folders error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при изменении порядка' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/directories/:id/folder', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { folderId } = req.body;
|
||||
const table = await moveDirectoryToFolder(id, req.organizationId!, folderId ?? null);
|
||||
if (!table) {
|
||||
return res.status(404).json({ success: false, error: 'Справочник не найден' });
|
||||
}
|
||||
res.json({ success: true, table });
|
||||
} catch (error) {
|
||||
console.error('Move directory to folder error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при перемещении справочника' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single directory/table with rows
|
||||
app.get(['/api/tables/:id', '/api/directories/:id'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const tableId = parseInt(req.params.id);
|
||||
const table = await storage.getDataTableFull(tableId, req.organizationId!);
|
||||
|
||||
if (!table) {
|
||||
return res.status(404).json({ error: 'Таблица не найдена' });
|
||||
}
|
||||
|
||||
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
||||
if (!role) {
|
||||
return res.status(403).json({ error: 'Нет доступа к таблице' });
|
||||
}
|
||||
|
||||
res.json({ table, role });
|
||||
} catch (error) {
|
||||
console.error('Get table error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения таблицы' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create directory/table
|
||||
app.post(['/api/tables', '/api/directories'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { name, description, columns } = req.body;
|
||||
|
||||
if (!name || !columns || columns.length === 0) {
|
||||
return res.status(400).json({ error: 'Название и колонки обязательны' });
|
||||
}
|
||||
|
||||
const table = await storage.createDataTable({
|
||||
name,
|
||||
description,
|
||||
columns,
|
||||
organizationId: req.organizationId!,
|
||||
createdBy: req.user!.id
|
||||
});
|
||||
|
||||
res.status(201).json({ table });
|
||||
} catch (error) {
|
||||
console.error('Create table error:', error);
|
||||
res.status(500).json({ error: 'Ошибка создания таблицы' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update directory/table
|
||||
app.patch(['/api/tables/:id', '/api/directories/:id'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const tableId = parseInt(req.params.id);
|
||||
const table = await storage.getDataTable(tableId, req.organizationId!);
|
||||
|
||||
if (!table) {
|
||||
return res.status(404).json({ error: 'Таблица не найдена' });
|
||||
}
|
||||
|
||||
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
||||
if (role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Нет прав для редактирования таблицы' });
|
||||
}
|
||||
|
||||
const { name, description, columns } = req.body;
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (columns !== undefined) updates.columns = columns;
|
||||
|
||||
const updatedTable = await storage.updateDataTable(tableId, req.organizationId!, updates as Parameters<typeof storage.updateDataTable>[2]);
|
||||
res.json({ table: updatedTable });
|
||||
} catch (error) {
|
||||
console.error('Update table error:', error);
|
||||
res.status(500).json({ error: 'Ошибка обновления таблицы' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete directory/table
|
||||
app.delete(['/api/tables/:id', '/api/directories/:id'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const tableId = parseInt(req.params.id);
|
||||
const table = await storage.getDataTable(tableId, req.organizationId!);
|
||||
|
||||
if (!table) {
|
||||
return res.status(404).json({ error: 'Таблица не найдена' });
|
||||
}
|
||||
|
||||
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
||||
if (role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Нет прав для удаления таблицы' });
|
||||
}
|
||||
|
||||
await storage.deleteDataTable(tableId, req.organizationId!);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete table error:', error);
|
||||
res.status(500).json({ error: 'Ошибка удаления таблицы' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// =====================================================
|
||||
// DATA TABLE ROWS API
|
||||
// =====================================================
|
||||
|
||||
// Column values for directories (autocomplete for filters)
|
||||
app.get(['/api/tables/:tableId/column-values', '/api/directories/:tableId/column-values'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const tableId = parseInt(req.params.tableId);
|
||||
const columnIdx = parseInt(req.query.column as string);
|
||||
const search = ((req.query.search as string) || '').toLowerCase();
|
||||
const contextStr = req.query.context as string;
|
||||
|
||||
const table = await storage.getDataTable(tableId, req.organizationId!);
|
||||
if (!table) return res.status(404).json({ error: 'Таблица не найдена' });
|
||||
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
||||
if (!role) return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
let rows = await storage.getDataTableRows(tableId, req.organizationId!);
|
||||
|
||||
if (contextStr) {
|
||||
try {
|
||||
const context: Record<string, string> = JSON.parse(contextStr);
|
||||
Object.entries(context).forEach(([col, val]) => {
|
||||
const ci = parseInt(col);
|
||||
if (!isNaN(ci) && val) {
|
||||
rows = rows.filter(r => {
|
||||
const v = Array.isArray(r.values) ? r.values : [];
|
||||
return String(v[ci] || '').toLowerCase().includes(val.toLowerCase());
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const values: string[] = [];
|
||||
for (const row of rows) {
|
||||
const rv = Array.isArray(row.values) ? row.values : [];
|
||||
const val = String(rv[columnIdx] || '').trim();
|
||||
if (!val || seen.has(val)) continue;
|
||||
if (search && !val.toLowerCase().includes(search)) continue;
|
||||
seen.add(val);
|
||||
values.push(val);
|
||||
if (values.length >= 20) break;
|
||||
}
|
||||
|
||||
res.json({ values });
|
||||
} catch (error) {
|
||||
console.error('Column values error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения значений' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get directory/table rows (with optional filter/sort)
|
||||
app.get(['/api/tables/:tableId/rows', '/api/directories/:tableId/rows'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const tableId = parseInt(req.params.tableId);
|
||||
const table = await storage.getDataTable(tableId, req.organizationId!);
|
||||
|
||||
if (!table) {
|
||||
return res.status(404).json({ error: 'Таблица не найдена' });
|
||||
}
|
||||
|
||||
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
||||
if (!role) {
|
||||
return res.status(403).json({ error: 'Нет доступа к таблице' });
|
||||
}
|
||||
|
||||
let rows = await storage.getDataTableRows(tableId, req.organizationId!);
|
||||
|
||||
const filtersStr = req.query.filters as string;
|
||||
if (filtersStr) {
|
||||
try {
|
||||
const filters: Record<string, string> = JSON.parse(filtersStr);
|
||||
Object.entries(filters).forEach(([col, val]) => {
|
||||
const ci = parseInt(col);
|
||||
if (!isNaN(ci) && val) {
|
||||
rows = rows.filter(r => {
|
||||
const v = Array.isArray(r.values) ? r.values : [];
|
||||
return String(v[ci] || '').toLowerCase().includes(val.toLowerCase());
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const sortColumn = req.query.sortColumn as string;
|
||||
const sortDir = req.query.sortDirection as string;
|
||||
if (sortColumn !== undefined && (sortDir === 'asc' || sortDir === 'desc')) {
|
||||
const ci = parseInt(sortColumn);
|
||||
if (!isNaN(ci)) {
|
||||
rows = [...rows].sort((a, b) => {
|
||||
const av = Array.isArray(a.values) ? a.values : [];
|
||||
const bv = Array.isArray(b.values) ? b.values : [];
|
||||
const aStr = String(av[ci] || '').toLowerCase();
|
||||
const bStr = String(bv[ci] || '').toLowerCase();
|
||||
const cmp = aStr.localeCompare(bStr, 'ru');
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ rows });
|
||||
} catch (error) {
|
||||
console.error('Get table rows error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения строк' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create table row
|
||||
app.post(['/api/tables/:tableId/rows', '/api/directories/:tableId/rows'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const tableId = parseInt(req.params.tableId);
|
||||
const table = await storage.getDataTable(tableId, req.organizationId!);
|
||||
|
||||
if (!table) {
|
||||
return res.status(404).json({ error: 'Таблица не найдена' });
|
||||
}
|
||||
|
||||
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
||||
if (!role || role === 'reader') {
|
||||
return res.status(403).json({ error: 'Нет прав для добавления строк' });
|
||||
}
|
||||
|
||||
const { values } = req.body;
|
||||
if (!values || !Array.isArray(values)) {
|
||||
return res.status(400).json({ error: 'Значения обязательны' });
|
||||
}
|
||||
|
||||
const row = await storage.createDataTableRow({ tableId, values });
|
||||
res.status(201).json({ row });
|
||||
} catch (error) {
|
||||
console.error('Create table row error:', error);
|
||||
res.status(500).json({ error: 'Ошибка создания строки' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update table row
|
||||
app.patch(['/api/tables/:tableId/rows/:rowId', '/api/directories/:tableId/rows/:rowId'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const tableId = parseInt(req.params.tableId);
|
||||
const rowId = parseInt(req.params.rowId);
|
||||
const table = await storage.getDataTable(tableId, req.organizationId!);
|
||||
|
||||
if (!table) {
|
||||
return res.status(404).json({ error: 'Таблица не найдена' });
|
||||
}
|
||||
|
||||
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
||||
if (!role || role === 'reader') {
|
||||
return res.status(403).json({ error: 'Нет прав для редактирования строк' });
|
||||
}
|
||||
|
||||
const { values } = req.body;
|
||||
if (!values || !Array.isArray(values)) {
|
||||
return res.status(400).json({ error: 'Значения обязательны' });
|
||||
}
|
||||
|
||||
const row = await storage.updateDataTableRow(rowId, tableId, req.organizationId!, { values });
|
||||
res.json({ row });
|
||||
} catch (error) {
|
||||
console.error('Update table row error:', error);
|
||||
res.status(500).json({ error: 'Ошибка обновления строки' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete table row
|
||||
app.delete(['/api/tables/:tableId/rows/:rowId', '/api/directories/:tableId/rows/:rowId'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const tableId = parseInt(req.params.tableId);
|
||||
const rowId = parseInt(req.params.rowId);
|
||||
const table = await storage.getDataTable(tableId, req.organizationId!);
|
||||
|
||||
if (!table) {
|
||||
return res.status(404).json({ error: 'Таблица не найдена' });
|
||||
}
|
||||
|
||||
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
||||
if (!role || role === 'reader') {
|
||||
return res.status(403).json({ error: 'Нет прав для удаления строк' });
|
||||
}
|
||||
|
||||
await storage.deleteDataTableRow(rowId, tableId, req.organizationId!);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete table row error:', error);
|
||||
res.status(500).json({ error: 'Ошибка удаления строки' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Log data table Excel/CSV export
|
||||
app.post(['/api/tables/:tableId/export', '/api/directories/:tableId/export'],
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const tableId = parseInt(req.params.tableId);
|
||||
const table = await storage.getDataTable(tableId, req.organizationId!);
|
||||
|
||||
if (!table) {
|
||||
return res.status(404).json({ error: 'Таблица не найдена' });
|
||||
}
|
||||
|
||||
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
||||
if (!role) {
|
||||
return res.status(403).json({ error: 'Нет доступа к таблице' });
|
||||
}
|
||||
|
||||
const exportFormat: string = req.body?.format ?? 'xlsx';
|
||||
logAudit({
|
||||
action: `export.table_${exportFormat}`,
|
||||
userId: req.user!.id,
|
||||
organizationId: req.organizationId!,
|
||||
details: {
|
||||
tableId,
|
||||
tableName: table.name,
|
||||
format: exportFormat,
|
||||
},
|
||||
ip: req.ip ?? null,
|
||||
userAgent: req.headers['user-agent'] ?? null,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Table export log error:', error);
|
||||
res.status(500).json({ error: 'Ошибка записи события экспорта' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user