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 { buildDataTableTree } from "../utils/data-table-tree"; import { clampDirectoryRowsLimit, clampDirectoryRowsOffset } from "../utils/data-table-rows-query"; 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, treeColumnIndex, treeDisplayColumnIndex, treeColumns, treeDisplayColumns } = req.body; const updates: Record = {}; if (name !== undefined) updates.name = name; if (description !== undefined) updates.description = description; if (columns !== undefined) updates.columns = columns; if (treeColumnIndex !== undefined) updates.treeColumnIndex = treeColumnIndex === null ? null : Number(treeColumnIndex); if (treeDisplayColumnIndex !== undefined) updates.treeDisplayColumnIndex = treeDisplayColumnIndex === null ? null : Number(treeDisplayColumnIndex); if (treeColumns !== undefined) updates.treeColumns = Array.isArray(treeColumns) ? treeColumns : null; if (treeDisplayColumns !== undefined) updates.treeDisplayColumns = Array.isArray(treeDisplayColumns) ? treeDisplayColumns : null; const updatedTable = await storage.updateDataTable(tableId, req.organizationId!, updates as Parameters[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 = 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(); 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: 'Нет доступа к таблице' }); } const sortColumn = req.query.sortColumn as string; const sortDir = req.query.sortDirection as string; // SQL-путь: при limit/offset/search фильтрация, сортировка и пагинация // выполняются на уровне БД (без полной выгрузки строк). // Без этих параметров — прежнее поведение (все строки + JS-фильтры/сортировка), // чтобы не менять семантику существующих клиентов. const useSqlPaging = req.query.limit !== undefined || req.query.offset !== undefined || req.query.search !== undefined; if (useSqlPaging) { let filters: Record | undefined; const filtersStr = req.query.filters as string; if (filtersStr) { try { const parsed: Record = JSON.parse(filtersStr); filters = {}; for (const [col, val] of Object.entries(parsed)) { const ci = parseInt(col); if (!isNaN(ci) && val) filters[ci] = String(val); } } catch {} } const ci = parseInt(sortColumn); const { rows, total } = await storage.getDataTableRowsPaged(tableId, req.organizationId!, { filters, search: req.query.search ? String(req.query.search) : undefined, sortColumn: !isNaN(ci) ? ci : undefined, sortDirection: sortDir === 'asc' || sortDir === 'desc' ? sortDir : undefined, limit: clampDirectoryRowsLimit(req.query.limit), offset: clampDirectoryRowsOffset(req.query.offset), }); return res.json({ rows, total }); } let rows = await storage.getDataTableRows(tableId, req.organizationId!); const filtersStr = req.query.filters as string; if (filtersStr) { try { const filters: Record = 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 {} } 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: 'Ошибка получения строк' }); } } ); // Get table rows as a tree (grouped by treeColumnIndex or parentId hierarchy) app.get(['/api/tables/:tableId/rows/tree', '/api/directories/:tableId/rows/tree'], 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 rows = await storage.getDataTableRows(tableId, req.organizationId!); const search = String(req.query.search || '').toLowerCase(); let filters: Record | undefined; const filtersStr = req.query.filters as string; if (filtersStr) { try { filters = JSON.parse(filtersStr); } catch {} } // Логика построения дерева вынесена в server/utils/data-table-tree.ts // (переиспользуется также MCP-сервером). res.json(buildDataTableTree(table, rows, { filters, search })); } catch (error) { console.error('Get table rows tree 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, parentId } = req.body; if (!values || !Array.isArray(values)) { return res.status(400).json({ error: 'Значения обязательны' }); } const row = await storage.createDataTableRow({ tableId, values, parentId: parentId ?? null }); res.status(201).json({ row }); } catch (error) { console.error('Create table row error:', error); res.status(500).json({ error: 'Ошибка создания строки' }); } } ); // Update table row (values and/or hierarchy) 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, parentId, position } = req.body; let row; if (parentId !== undefined || position !== undefined) { row = await storage.moveDataTableRow( rowId, tableId, req.organizationId!, parentId ?? null, position ?? 0 ); } if (values !== undefined) { if (!Array.isArray(values)) { return res.status(400).json({ error: 'Значения должны быть массивом' }); } row = await storage.updateDataTableRow(rowId, tableId, req.organizationId!, { values }); } if (!row) { return res.status(400).json({ error: 'Не переданы данные для обновления' }); } res.json({ row }); } catch (error: any) { console.error('Update table row error:', error); res.status(400).json({ error: error.message || 'Ошибка обновления строки' }); } } ); // 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: 'Ошибка удаления строки' }); } } ); // ===================================================== // DATA TABLE LINKS API (подсправочники) // ===================================================== // Get subdirectories for a parent table app.get(['/api/tables/:id/subdirectories', '/api/directories/:id/subdirectories'], 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) { return res.status(403).json({ error: 'Нет доступа к таблице' }); } const links = await storage.getDataTableLinks(tableId, req.organizationId!); res.json({ subdirectories: links }); } catch (error) { console.error('Get subdirectories error:', error); res.status(500).json({ error: 'Ошибка получения подсправочников' }); } } ); // Create a subdirectory link app.post(['/api/tables/:id/subdirectories', '/api/directories/:id/subdirectories'], 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 { childTableId, parentColumnIndex, childColumnIndex, parentRowIds } = req.body; if (!childTableId || typeof childTableId !== 'number') { return res.status(400).json({ error: 'Необходимо указать подсправочник (childTableId)' }); } const childTable = await storage.getDataTable(childTableId, req.organizationId!); if (!childTable) { return res.status(404).json({ error: 'Подсправочник не найден' }); } const link = await storage.createDataTableLink({ organizationId: req.organizationId!, parentTableId: tableId, childTableId, parentColumnIndex: parentColumnIndex ?? 0, childColumnIndex: childColumnIndex ?? 0, parentRowIds: Array.isArray(parentRowIds) ? parentRowIds : [], }); res.status(201).json({ link }); } catch (error: any) { console.error('Create subdirectory error:', error); res.status(400).json({ error: error.message || 'Ошибка создания подсправочника' }); } } ); // Update a subdirectory link app.patch(['/api/tables/:id/subdirectories/:linkId', '/api/directories/:id/subdirectories/:linkId'], authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const tableId = parseInt(req.params.id); const linkId = parseInt(req.params.linkId); 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 existing = await storage.getDataTableLink(linkId, req.organizationId!); if (!existing || existing.parentTableId !== tableId) { return res.status(404).json({ error: 'Связь не найдена' }); } const { childTableId, parentColumnIndex, childColumnIndex, parentRowIds } = req.body; const updates: Partial = {}; if (childTableId !== undefined) updates.childTableId = childTableId; if (parentColumnIndex !== undefined) updates.parentColumnIndex = parentColumnIndex; if (childColumnIndex !== undefined) updates.childColumnIndex = childColumnIndex; if (parentRowIds !== undefined) updates.parentRowIds = Array.isArray(parentRowIds) ? parentRowIds : []; const link = await storage.updateDataTableLink(linkId, req.organizationId!, updates); res.json({ link }); } catch (error: any) { console.error('Update subdirectory error:', error); res.status(400).json({ error: error.message || 'Ошибка обновления подсправочника' }); } } ); // Delete a subdirectory link app.delete(['/api/tables/:id/subdirectories/:linkId', '/api/directories/:id/subdirectories/:linkId'], authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const tableId = parseInt(req.params.id); const linkId = parseInt(req.params.linkId); 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 existing = await storage.getDataTableLink(linkId, req.organizationId!); if (!existing || existing.parentTableId !== tableId) { return res.status(404).json({ error: 'Связь не найдена' }); } await storage.deleteDataTableLink(linkId, req.organizationId!); res.json({ success: true }); } catch (error: any) { console.error('Delete subdirectory error:', error); res.status(400).json({ error: error.message || 'Ошибка удаления подсправочника' }); } } ); // 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: 'Ошибка записи события экспорта' }); } } ); }