diff --git a/server/mcp.ts b/server/mcp.ts index 193a45c..5bec0f6 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -42,6 +42,7 @@ import { type EntityType, } from "./services/embedding.service"; import { formatUserName } from "./utils/formatUserName"; +import { buildDataTableTree } from "./utils/data-table-tree"; /** Format JS page code with consistent indentation before storing in DB. */ function formatPageCode(code: string): string { @@ -113,6 +114,11 @@ const READ_TOOLS: readonly string[] = [ 'get_js_coding_reference', 'semantic_search', 'get_organization_context', + 'list_directories', + 'get_directory', + 'list_directory_rows', + 'get_directory_row', + 'get_directory_column_values', ]; // WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных. @@ -120,6 +126,8 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [ 'create_task', 'append_table_row', 'link_tasks', + 'create_directory_row', + 'bulk_create_directory_rows', ]; // Все остальные инструменты (изменение/удаление форм, задач, пользователей, @@ -199,6 +207,32 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer isError: true, }); + // ── Объектный доступ по scopes.tableIds (справочники) ───────────────────── + const isTableAllowed = (tableId: number) => scopes.tableIds === null || scopes.tableIds.includes(tableId); + const tableDenied = (tableId: number) => ({ + content: [{ type: 'text' as const, text: JSON.stringify({ error: `Доступ к справочнику ${tableId} запрещён правами API-ключа` }) }], + isError: true, + }); + + // Ошибка инструмента справочников в едином формате { error } + const directoryError = (message: string) => ({ + content: [{ type: 'text' as const, text: JSON.stringify({ error: message }) }], + isError: true, + }); + + // Нормализация values строки справочника до длины массива columns: + // лишние значения обрезаются, недостающие дополняются пустыми строками + // (как normalizeValues в server/routes/data-tables-sync.routes.ts). + const normalizeRowValues = (values: (string | null | undefined)[] | null | undefined, columnCount: number): string[] => { + const arr = values ?? []; + const result: string[] = []; + for (let i = 0; i < columnCount; i++) { + const val = arr[i]; + result.push(val === null || val === undefined ? '' : String(val).trim()); + } + return result; + }; + // list_forms register( "list_forms", @@ -2895,6 +2929,614 @@ To block task creation from task.before_create, set: ctx.result = { allow: false } ); + // ── Справочники (Data Tables / Directories) ────────────────────────────── + + // list_directories + register( + "list_directories", + { + title: "List Directories", + description: "List all directories (data tables / справочники) in the organization with their columns and row counts", + inputSchema: {}, + }, + async () => { + const all = await storage.getDataTablesByOrganization(organizationId); + // Фильтруем выдачу по scopes.tableIds (null = все справочники) + const allowed = all.filter((t) => isTableAllowed(t.id)); + const result = await Promise.all( + allowed.map(async (t) => { + const rows = await storage.getDataTableRows(t.id, organizationId); + return { + id: t.id, + name: t.name, + description: t.description, + columns: t.columns, + rowCount: rows.length, + }; + }) + ); + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + }; + } + ); + + // get_directory + register( + "get_directory", + { + title: "Get Directory", + description: "Get a directory (data table) by ID: columns, tree settings and subdirectories (linked child tables)", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table)"), + }, + }, + async ({ tableId }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const table = await storage.getDataTable(tableId, organizationId); + if (!table) return directoryError(`Справочник ${tableId} не найден`); + const links = await storage.getDataTableLinks(tableId, organizationId); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + id: table.id, + name: table.name, + description: table.description, + columns: table.columns, + treeColumnIndex: table.treeColumnIndex, + treeDisplayColumnIndex: table.treeDisplayColumnIndex, + treeColumns: table.treeColumns, + treeDisplayColumns: table.treeDisplayColumns, + visibility: table.visibility, + createdAt: table.createdAt, + updatedAt: table.updatedAt, + subdirectories: links.map((l) => ({ + id: l.id, + parentTableId: l.parentTableId, + childTableId: l.childTableId, + parentColumnIndex: l.parentColumnIndex, + childColumnIndex: l.childColumnIndex, + parentRowIds: l.parentRowIds, + childTable: l.childTable ?? null, + })), + }, null, 2), + }], + }; + } + ); + + // list_directory_rows + register( + "list_directory_rows", + { + title: "List Directory Rows", + description: + "List rows of a directory (data table). " + + "mode='flat' (default): paginated flat list with optional filters/search/sort. " + + "mode='tree': hierarchical tree (grouped by treeColumns or by parentId hierarchy, as in the web UI); " + + "in tree mode filters use exact match, limit/offset/sort are ignored. " + + "values are positional: values[i] corresponds to columns[i] of the directory.", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table)"), + filters: z.record(z.string(), z.string()).optional().describe("Column index (as string key) → substring filter; flat mode: 'includes' case-insensitive, tree mode: exact match"), + search: z.string().optional().describe("Substring search across all values (case-insensitive)"), + sortColumn: z.number().int().optional().describe("Column index to sort by (flat mode only)"), + sortDirection: z.enum(["asc", "desc"]).optional().describe("Sort direction (flat mode only)"), + limit: z.number().int().min(1).max(1000).optional().describe("Max rows to return (default 100, max 1000; flat mode only)"), + offset: z.number().int().min(0).optional().describe("Rows to skip (default 0; flat mode only)"), + mode: z.enum(["flat", "tree"]).optional().describe("'flat' (default) or 'tree'"), + }, + }, + async ({ tableId, filters, search, sortColumn, sortDirection, limit, offset, mode }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const table = await storage.getDataTable(tableId, organizationId); + if (!table) return directoryError(`Справочник ${tableId} не найден`); + + let rows = await storage.getDataTableRows(tableId, organizationId); + + if (mode === "tree") { + const tree = buildDataTableTree(table, rows, { filters, search }); + return { + content: [{ type: "text" as const, text: JSON.stringify(tree, null, 2) }], + }; + } + + // flat: фильтры по подстроке (includes), без учёта регистра + if (filters) { + 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()); + }); + } + }); + } + + // Поиск по всем значениям строки + if (search) { + const q = search.toLowerCase(); + rows = rows.filter((r) => { + const v = Array.isArray(r.values) ? r.values : []; + return v.some((val) => String(val ?? '').toLowerCase().includes(q)); + }); + } + + // Сортировка по колонке + if (sortColumn !== undefined && (sortDirection === 'asc' || sortDirection === 'desc')) { + const ci = sortColumn; + rows = [...rows].sort((a, b) => { + const av = Array.isArray(a.values) ? a.values : []; + const bv = Array.isArray(b.values) ? b.values : []; + const cmp = String(av[ci] || '').toLowerCase().localeCompare(String(bv[ci] || '').toLowerCase(), 'ru'); + return sortDirection === 'asc' ? cmp : -cmp; + }); + } + + const total = rows.length; + const paged = rows.slice(offset ?? 0, (offset ?? 0) + (limit ?? 100)); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + rows: paged.map((r) => ({ id: r.id, parentId: r.parentId, values: r.values, position: r.position })), + total, + }, null, 2), + }], + }; + } + ); + + // get_directory_row + register( + "get_directory_row", + { + title: "Get Directory Row", + description: "Get a single row of a directory (data table) by ID", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table)"), + rowId: z.number().int().describe("The numeric ID of the row"), + }, + }, + async ({ tableId, rowId }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const row = await storage.getDataTableRow(rowId, tableId, organizationId); + if (!row) return directoryError(`Строка ${rowId} не найдена в справочнике ${tableId}`); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ id: row.id, tableId: row.tableId, parentId: row.parentId, values: row.values, position: row.position }, null, 2), + }], + }; + } + ); + + // get_directory_column_values + register( + "get_directory_column_values", + { + title: "Get Directory Column Values", + description: "Get unique values of a directory column (autocomplete for filters), optionally filtered by a search substring", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table)"), + column: z.number().int().describe("Column index (0-based)"), + search: z.string().optional().describe("Substring filter on values (case-insensitive)"), + limit: z.number().int().min(1).max(200).optional().describe("Max values to return (default 20)"), + }, + }, + async ({ tableId, column, search, limit }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const table = await storage.getDataTable(tableId, organizationId); + if (!table) return directoryError(`Справочник ${tableId} не найден`); + if (column < 0 || column >= (table.columns?.length ?? 0)) { + return directoryError(`Колонка ${column} вне диапазона (в справочнике ${table.columns?.length ?? 0} колонок)`); + } + const rows = await storage.getDataTableRows(tableId, organizationId); + const q = (search ?? '').toLowerCase(); + const seen = new Set(); + const values: string[] = []; + for (const row of rows) { + const rv = Array.isArray(row.values) ? row.values : []; + const val = String(rv[column] || '').trim(); + if (!val || seen.has(val)) continue; + if (q && !val.toLowerCase().includes(q)) continue; + seen.add(val); + values.push(val); + if (values.length >= (limit ?? 20)) break; + } + return { + content: [{ type: "text" as const, text: JSON.stringify({ column, values }, null, 2) }], + }; + } + ); + + // create_directory_row + register( + "create_directory_row", + { + title: "Create Directory Row", + description: + "Create a new row in a directory (data table). values are positional: values[i] corresponds to columns[i]. " + + "The row is appended at the end of its siblings (same parentId). " + + "Use list_directory_rows to discover existing row IDs for parentId.", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table)"), + values: z.array(z.string()).describe("Positional values: values[i] = columns[i]. Extra values are truncated, missing ones filled with empty strings."), + parentId: z.number().int().nullable().optional().describe("Parent row ID for hierarchical directories (optional, null = root)"), + }, + }, + async ({ tableId, values, parentId }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const table = await storage.getDataTable(tableId, organizationId); + if (!table) return directoryError(`Справочник ${tableId} не найден`); + + const columnCount = table.columns?.length ?? values.length; + const normalizedValues = normalizeRowValues(values, columnCount); + const targetParentId = parentId ?? null; + + const existingRows = await storage.getDataTableRows(tableId, organizationId); + if (targetParentId !== null && !existingRows.some((r) => r.id === targetParentId)) { + return directoryError(`Родительская строка ${targetParentId} не найдена в справочнике ${tableId}`); + } + + // Ставим строку в конец среди siblings с тем же parentId + const siblings = existingRows.filter((r) => (r.parentId ?? null) === targetParentId); + const position = siblings.length > 0 ? Math.max(...siblings.map((s) => s.position)) + 1 : 0; + + const row = await storage.createDataTableRow({ + tableId, + values: normalizedValues, + parentId: targetParentId, + position, + }); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, row: { id: row.id, tableId: row.tableId, parentId: row.parentId, values: row.values, position: row.position } }, null, 2), + }], + }; + } + ); + + // bulk_create_directory_rows + register( + "bulk_create_directory_rows", + { + title: "Bulk Create Directory Rows", + description: + "Create multiple rows in a directory (data table) at once. values are positional: values[i] = columns[i]. " + + "Positions are assigned sequentially at the end of each parent group.", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table)"), + rows: z.array(z.object({ + values: z.array(z.string()).describe("Positional values: values[i] = columns[i]"), + parentId: z.number().int().nullable().optional().describe("Parent row ID (optional, null = root)"), + })).min(1).describe("Rows to create"), + }, + }, + async ({ tableId, rows: inputRows }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const table = await storage.getDataTable(tableId, organizationId); + if (!table) return directoryError(`Справочник ${tableId} не найден`); + + const columnCount = table.columns?.length ?? 0; + const existingRows = await storage.getDataTableRows(tableId, organizationId); + const existingIds = new Set(existingRows.map((r) => r.id)); + for (const r of inputRows) { + if (r.parentId != null && !existingIds.has(r.parentId)) { + return directoryError(`Родительская строка ${r.parentId} не найдена в справочнике ${tableId}`); + } + } + + // Позиции: продолжаем от максимальной позиции среди siblings каждой группы parentId + const nextPos = new Map(); + for (const r of existingRows) { + const key = String(r.parentId ?? 'root'); + nextPos.set(key, Math.max(nextPos.get(key) ?? -1, r.position)); + } + const payload = inputRows.map((r) => { + const key = String(r.parentId ?? 'root'); + const position = (nextPos.get(key) ?? -1) + 1; + nextPos.set(key, position); + return { + values: normalizeRowValues(r.values, columnCount), + parentId: r.parentId ?? null, + position, + }; + }); + + const created = await storage.bulkCreateDataTableRows(tableId, payload); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: true, + createdCount: created.length, + rows: created.map((r) => ({ id: r.id, parentId: r.parentId, values: r.values, position: r.position })), + }, null, 2), + }], + }; + } + ); + + // create_directory + register( + "create_directory", + { + title: "Create Directory", + description: "Create a new directory (data table / справочник) with the given columns", + inputSchema: { + name: z.string().min(1).describe("Directory name"), + description: z.string().optional().describe("Optional description"), + columns: z.array(z.object({ + name: z.string().min(1).describe("Column name"), + type: z.enum(['text', 'number', 'date', 'checkbox', 'select', 'user', 'datetime']).optional().describe("Column type (default 'text')"), + options: z.array(z.string()).optional().describe("For 'select' type: list of options"), + isRequired: z.boolean().optional().describe("Whether the column is required"), + })).min(1).describe("Columns definition (at least one)"), + }, + }, + async ({ name, description, columns }) => { + const orgUsers = await storage.getUsersByOrganization(organizationId); + const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; + if (!adminUser) { + return directoryError("В организации нет пользователей для назначения createdBy"); + } + + const table = await storage.createDataTable({ + name, + description: description ?? null, + columns: columns.map((c) => ({ ...c, type: c.type ?? 'text' })), + organizationId, + createdBy: adminUser.id, + }); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, directory: { id: table.id, name: table.name, description: table.description, columns: table.columns } }, null, 2), + }], + }; + } + ); + + // update_directory + register( + "update_directory", + { + title: "Update Directory", + description: "Update directory (data table) properties: name, description, columns, tree settings", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table)"), + name: z.string().min(1).optional().describe("New name"), + description: z.string().nullable().optional().describe("New description (null to clear)"), + columns: z.array(z.object({ + name: z.string().min(1), + type: z.enum(['text', 'number', 'date', 'checkbox', 'select', 'user', 'datetime']).optional(), + options: z.array(z.string()).optional(), + isRequired: z.boolean().optional(), + })).min(1).optional().describe("New columns definition. WARNING: values are positional — changing column order/count shifts existing row values."), + treeColumns: z.array(z.number().int()).nullable().optional().describe("Column indexes for tree grouping (null to clear)"), + treeDisplayColumns: z.array(z.number().int()).nullable().optional().describe("Column indexes used for tree node labels (null to clear)"), + }, + }, + async ({ tableId, name, description, columns, treeColumns, treeDisplayColumns }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const table = await storage.getDataTable(tableId, organizationId); + if (!table) return directoryError(`Справочник ${tableId} не найден`); + + const updates: Record = {}; + if (name !== undefined) updates.name = name; + if (description !== undefined) updates.description = description; + if (columns !== undefined) updates.columns = columns.map((c) => ({ ...c, type: c.type ?? 'text' })); + if (treeColumns !== undefined) updates.treeColumns = treeColumns; + if (treeDisplayColumns !== undefined) updates.treeDisplayColumns = treeDisplayColumns; + if (Object.keys(updates).length === 0) { + return directoryError("Не переданы данные для обновления"); + } + + const updated = await storage.updateDataTable(tableId, organizationId, updates); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, directory: { id: updated.id, name: updated.name, description: updated.description, columns: updated.columns, treeColumns: updated.treeColumns, treeDisplayColumns: updated.treeDisplayColumns } }, null, 2), + }], + }; + } + ); + + // delete_directory + register( + "delete_directory", + { + title: "Delete Directory", + description: "Delete a directory (data table). Soft-delete: rows are kept in the database but the directory disappears from the UI and API.", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table) to delete"), + }, + }, + async ({ tableId }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const table = await storage.getDataTable(tableId, organizationId); + if (!table) return directoryError(`Справочник ${tableId} не найден`); + await storage.deleteDataTable(tableId, organizationId); + return { + content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted: { id: tableId, name: table.name } }, null, 2) }], + }; + } + ); + + // update_directory_row + register( + "update_directory_row", + { + title: "Update Directory Row", + description: "Update values of a directory row. values are positional and replace the whole row: values[i] = columns[i].", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table)"), + rowId: z.number().int().describe("The numeric ID of the row"), + values: z.array(z.string()).describe("New positional values (full replacement). Extra values are truncated, missing ones filled with empty strings."), + }, + }, + async ({ tableId, rowId, values }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const table = await storage.getDataTable(tableId, organizationId); + if (!table) return directoryError(`Справочник ${tableId} не найден`); + const existing = await storage.getDataTableRow(rowId, tableId, organizationId); + if (!existing) return directoryError(`Строка ${rowId} не найдена в справочнике ${tableId}`); + + const columnCount = table.columns?.length ?? values.length; + const row = await storage.updateDataTableRow(rowId, tableId, organizationId, { + values: normalizeRowValues(values, columnCount), + }); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, row: { id: row.id, parentId: row.parentId, values: row.values, position: row.position } }, null, 2), + }], + }; + } + ); + + // delete_directory_row + register( + "delete_directory_row", + { + title: "Delete Directory Row", + description: "Delete a directory row (soft-delete). Its child rows are moved to the root (parentId = null).", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table)"), + rowId: z.number().int().describe("The numeric ID of the row to delete"), + }, + }, + async ({ tableId, rowId }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const existing = await storage.getDataTableRow(rowId, tableId, organizationId); + if (!existing) return directoryError(`Строка ${rowId} не найдена в справочнике ${tableId}`); + await storage.deleteDataTableRow(rowId, tableId, organizationId); + return { + content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted: { id: rowId, tableId } }, null, 2) }], + }; + } + ); + + // move_directory_row + register( + "move_directory_row", + { + title: "Move Directory Row", + description: + "Move a directory row to a new parent and/or position. " + + "position is the index among siblings with the same parentId (0-based, clamped). " + + "Cycle protection: a row cannot be moved into itself or its descendant.", + inputSchema: { + tableId: z.number().int().describe("The numeric ID of the directory (data table)"), + rowId: z.number().int().describe("The numeric ID of the row to move"), + parentId: z.number().int().nullable().describe("New parent row ID (null = root)"), + position: z.number().int().min(0).describe("Position among siblings with the same parentId (0-based)"), + }, + }, + async ({ tableId, rowId, parentId, position }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const table = await storage.getDataTable(tableId, organizationId); + if (!table) return directoryError(`Справочник ${tableId} не найден`); + try { + const row = await storage.moveDataTableRow(rowId, tableId, organizationId, parentId, position); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, row: { id: row.id, parentId: row.parentId, values: row.values, position: row.position } }, null, 2), + }], + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return directoryError(`Ошибка перемещения строки: ${msg}`); + } + } + ); + + // link_directories + register( + "link_directories", + { + title: "Link Directories", + description: + "Link a child directory to a parent directory (creates a subdirectory). " + + "Upsert: an existing link for the same parent/child pair is updated. " + + "parentColumnIndex/childColumnIndex define the join columns; parentRowIds limits the link to specific parent rows.", + inputSchema: { + parentTableId: z.number().int().describe("ID of the parent directory"), + childTableId: z.number().int().describe("ID of the child (sub)directory"), + parentColumnIndex: z.number().int().min(0).optional().describe("Join column index in the parent directory (default 0)"), + childColumnIndex: z.number().int().min(0).optional().describe("Join column index in the child directory (default 0)"), + parentRowIds: z.array(z.number().int()).optional().describe("Limit the link to these parent row IDs (default: all rows)"), + }, + }, + async ({ parentTableId, childTableId, parentColumnIndex, childColumnIndex, parentRowIds }) => { + if (!isTableAllowed(parentTableId)) return tableDenied(parentTableId); + if (!isTableAllowed(childTableId)) return tableDenied(childTableId); + const [parentTable, childTable] = await Promise.all([ + storage.getDataTable(parentTableId, organizationId), + storage.getDataTable(childTableId, organizationId), + ]); + if (!parentTable) return directoryError(`Родительский справочник ${parentTableId} не найден`); + if (!childTable) return directoryError(`Дочерний справочник ${childTableId} не найден`); + if (parentTableId === childTableId) return directoryError("Нельзя связать справочник сам с собой"); + + const link = await storage.createDataTableLink({ + organizationId, + parentTableId, + childTableId, + parentColumnIndex, + childColumnIndex, + parentRowIds, + }); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: true, + link: { + id: link.id, + parentTableId: link.parentTableId, + childTableId: link.childTableId, + parentColumnIndex: link.parentColumnIndex, + childColumnIndex: link.childColumnIndex, + parentRowIds: link.parentRowIds, + }, + }, null, 2), + }], + }; + } + ); + + // unlink_directories + register( + "unlink_directories", + { + title: "Unlink Directories", + description: "Remove a link between directories by link ID. Use get_directory on the parent table to discover link IDs.", + inputSchema: { + linkId: z.number().int().describe("The numeric ID of the link to remove"), + }, + }, + async ({ linkId }) => { + const link = await storage.getDataTableLink(linkId, organizationId); + if (!link) return directoryError(`Связь ${linkId} не найдена`); + // Проверяем доступ к обеим связанным таблицам + if (!isTableAllowed(link.parentTableId)) return tableDenied(link.parentTableId); + if (!isTableAllowed(link.childTableId)) return tableDenied(link.childTableId); + await storage.deleteDataTableLink(linkId, organizationId); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, deleted: { id: linkId, parentTableId: link.parentTableId, childTableId: link.childTableId } }, null, 2), + }], + }; + } + ); + return server; } diff --git a/server/routes/data-tables.routes.ts b/server/routes/data-tables.routes.ts index 061bfc1..c83fd5e 100644 --- a/server/routes/data-tables.routes.ts +++ b/server/routes/data-tables.routes.ts @@ -4,6 +4,7 @@ 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 { getDirectoryFolders, createDirectoryFolder, @@ -503,157 +504,20 @@ export function registerDataTableRoutes(app: import("express").Express): void { return res.status(403).json({ error: 'Нет доступа' }); } - let rows = await storage.getDataTableRows(tableId, req.organizationId!); - const treeColumnIndex = table.treeColumnIndex; - const treeDisplayColumnIndex = table.treeDisplayColumnIndex; + 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 { - 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() === String(val).toLowerCase(); - }); - } - }); + filters = JSON.parse(filtersStr); } catch {} } - const treeColumns = table.treeColumns ?? (treeColumnIndex != null ? [treeColumnIndex] : []); - const treeDisplayColumns = table.treeDisplayColumns ?? (treeDisplayColumnIndex != null ? [treeDisplayColumnIndex] : [0]); - const displayColumnIndex = treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0; - - const getRowLabel = (row: typeof rows[number]): string => { - const values = Array.isArray(row.values) ? row.values : []; - // Последнее заполненное значение из treeDisplayColumns - for (let i = treeDisplayColumns.length - 1; i >= 0; i--) { - const val = String(values[treeDisplayColumns[i]] ?? '').trim(); - if (val) return val; - } - // Fallback: первая заполненная колонка - for (const val of values) { - const str = String(val ?? '').trim(); - if (str) return str; - } - return '(без названия)'; - }; - - type TreeNode = - | { type: 'group'; id: string; label: string; level: number; children: TreeNode[] } - | { type: 'row'; id: number; label: string; level: number; row: typeof rows[number] }; - - const buildParentTree = (parentRows: typeof rows, parentId: number | null, level = 0): TreeNode[] => { - const items = parentRows - .filter((r) => (r.parentId ?? null) === parentId) - .sort((a, b) => a.position - b.position); - return items.map((row) => { - const children = buildParentTree(parentRows, row.id, level + 1); - const label = getRowLabel(row); - if (children.length > 0) { - return { type: 'group' as const, id: `row-group:${row.id}`, label, level, children: [...children, { type: 'row' as const, id: row.id, label, level: level + 1, row }] }; - } - return { type: 'row' as const, id: row.id, label, level, row }; - }); - }; - - const buildMultiColumnTree = (allRows: typeof rows, columns: number[], level = 0): TreeNode[] => { - if (level >= columns.length) { - return allRows - .sort((a, b) => a.position - b.position) - .map((row) => ({ type: 'row' as const, id: row.id, label: getRowLabel(row), level, row })); - } - - const columnIndex = columns[level]; - const groups = new Map(); - const rootRows: typeof rows[number][] = []; - - for (const row of allRows) { - const vals = Array.isArray(row.values) ? row.values : []; - const key = String(vals[columnIndex] ?? '').trim(); - if (!key) { - rootRows.push(row); - continue; - } - if (!groups.has(key)) groups.set(key, []); - groups.get(key)!.push(row); - } - - const nodes: TreeNode[] = []; - for (const [label, groupRows] of groups) { - // Если у группы все строки имеют пустое значение в treeDisplayColumns, - // группа становится конечной строкой (можно выбрать в задачах) - const allEmpty = groupRows.every((row) => { - const vals = Array.isArray(row.values) ? row.values : []; - return treeDisplayColumns.every((ci) => !String(vals[ci] ?? '').trim()); - }); - - if (allEmpty && groupRows.length === 1) { - nodes.push({ - type: 'row', - id: groupRows[0].id, - label, - level, - row: groupRows[0], - }); - } else { - nodes.push({ - type: 'group', - id: `group:${level}:${label}`, - label, - level, - children: buildMultiColumnTree(groupRows, columns, level + 1), - }); - } - } - - if (rootRows.length > 0) { - nodes.push(...buildMultiColumnTree(rootRows, columns, level + 1)); - } - - return nodes.sort((a, b) => { - const al = a.type === 'group' ? a.label : getRowLabel(a.row); - const bl = b.type === 'group' ? b.label : getRowLabel(b.row); - return al.localeCompare(bl, 'ru'); - }); - }; - - const filterNodes = (nodes: TreeNode[], query: string): TreeNode[] => { - if (!query) return nodes; - const result: TreeNode[] = []; - for (const node of nodes) { - if (node.type === 'row') { - const label = node.label.toLowerCase(); - if (label.includes(query)) result.push(node); - } else { - const children = filterNodes(node.children, query); - if (children.length > 0) { - result.push({ ...node, children }); - } else if (node.label.toLowerCase().includes(query)) { - result.push({ ...node, children: [] }); - } - } - } - return result; - }; - - const rawNodes = treeColumns.length > 0 - ? buildMultiColumnTree(rows, treeColumns) - : buildParentTree(rows, null); - const nodes = search ? filterNodes(rawNodes, search) : rawNodes; - - res.json({ - columnIndex: treeColumnIndex, - treeColumnIndex, - treeColumns, - treeDisplayColumns, - displayColumnIndex, - nodes, - }); + // Логика построения дерева вынесена в 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: 'Ошибка получения дерева строк' }); diff --git a/server/storage.ts b/server/storage.ts index 1dce3b9..300537b 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -292,7 +292,7 @@ export interface IStorage { createDataTableRow(row: InsertDataTableRow): Promise; updateDataTableRow(id: number, tableId: number, organizationId: number, updates: Partial): Promise; deleteDataTableRow(id: number, tableId: number, organizationId: number): Promise; - bulkCreateDataTableRows(tableId: number, rows: { values: string[] }[]): Promise; + bulkCreateDataTableRows(tableId: number, rows: { values: string[]; parentId?: number | null; position?: number }[]): Promise; bulkDeleteDataTableRows(tableId: number, organizationId: number): Promise; applyDataTableSync(tableId: number, deleted: { id: number }[], added: { values: string[]; position: number }[], reordered: { id: number; newPosition: number }[], positionUpdates: { id: number; position: number }[]): Promise; applyDataTableDiff(tableId: number, upsertRows: { values: string[] }[], deleteKeys: string[], currentRows: DataTableRow[]): Promise<{ added: DataTableRow[]; updated: DataTableRow[]; deleted: DataTableRow[] }>; diff --git a/server/storage/data-tables-core.storage.ts b/server/storage/data-tables-core.storage.ts index 87c7e6c..acdae38 100644 --- a/server/storage/data-tables-core.storage.ts +++ b/server/storage/data-tables-core.storage.ts @@ -318,12 +318,14 @@ export class DataTablesCoreStorage extends BotsStorage { return movedRow; } - async bulkCreateDataTableRows(tableId: number, rows: { values: string[] }[]): Promise { + async bulkCreateDataTableRows(tableId: number, rows: { values: string[]; parentId?: number | null; position?: number }[]): Promise { if (rows.length === 0) return []; const insertData = rows.map(row => ({ tableId, values: row.values, + parentId: row.parentId ?? null, + position: row.position ?? 0, })); return await db diff --git a/server/utils/data-table-tree.ts b/server/utils/data-table-tree.ts new file mode 100644 index 0000000..b83fdcb --- /dev/null +++ b/server/utils/data-table-tree.ts @@ -0,0 +1,181 @@ +import type { DataTable, DataTableRow } from "@shared/schema"; + +// Узел дерева строк справочника: +// group — виртуальная группа (по значению колонки или строка с детьми), +// row — конкретная строка справочника. +export type DataTableTreeNode = + | { type: 'group'; id: string; label: string; level: number; children: DataTableTreeNode[] } + | { type: 'row'; id: number; label: string; level: number; row: DataTableRow }; + +export interface DataTableTreeResult { + columnIndex: number | null; + treeColumnIndex: number | null; + treeColumns: number[]; + treeDisplayColumns: number[]; + displayColumnIndex: number; + nodes: DataTableTreeNode[]; +} + +export interface BuildDataTableTreeOptions { + // Фильтры: индекс колонки (строкой) → значение; сравнение точное, без учёта регистра. + filters?: Record; + // Подстрока для фильтрации узлов дерева по label (без учёта регистра). + search?: string; +} + +// Строит дерево строк справочника. +// Два режима (как в GET /api/tables/:tableId/rows/tree): +// - treeColumns заданы → группировка по значениям этих колонок (мультиуровневая); +// - иначе → иерархия по parentId. +// Логика вынесена из server/routes/data-tables.routes.ts — поведение не менять. +export function buildDataTableTree( + table: DataTable, + rows: DataTableRow[], + options?: BuildDataTableTreeOptions +): DataTableTreeResult { + let filteredRows = rows; + + const filters = options?.filters; + if (filters) { + Object.entries(filters).forEach(([col, val]) => { + const ci = parseInt(col); + if (!isNaN(ci) && val) { + filteredRows = filteredRows.filter((r) => { + const v = Array.isArray(r.values) ? r.values : []; + return String(v[ci] || '').toLowerCase() === String(val).toLowerCase(); + }); + } + }); + } + + const search = (options?.search ?? '').toLowerCase(); + + const treeColumnIndex = table.treeColumnIndex; + const treeDisplayColumnIndex = table.treeDisplayColumnIndex; + const treeColumns = table.treeColumns ?? (treeColumnIndex != null ? [treeColumnIndex] : []); + const treeDisplayColumns = table.treeDisplayColumns ?? (treeDisplayColumnIndex != null ? [treeDisplayColumnIndex] : [0]); + const displayColumnIndex = treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0; + + const getRowLabel = (row: DataTableRow): string => { + const values = Array.isArray(row.values) ? row.values : []; + // Последнее заполненное значение из treeDisplayColumns + for (let i = treeDisplayColumns.length - 1; i >= 0; i--) { + const val = String(values[treeDisplayColumns[i]] ?? '').trim(); + if (val) return val; + } + // Fallback: первая заполненная колонка + for (const val of values) { + const str = String(val ?? '').trim(); + if (str) return str; + } + return '(без названия)'; + }; + + const buildParentTree = (parentRows: DataTableRow[], parentId: number | null, level = 0): DataTableTreeNode[] => { + const items = parentRows + .filter((r) => (r.parentId ?? null) === parentId) + .sort((a, b) => a.position - b.position); + return items.map((row) => { + const children = buildParentTree(parentRows, row.id, level + 1); + const label = getRowLabel(row); + if (children.length > 0) { + return { type: 'group' as const, id: `row-group:${row.id}`, label, level, children: [...children, { type: 'row' as const, id: row.id, label, level: level + 1, row }] }; + } + return { type: 'row' as const, id: row.id, label, level, row }; + }); + }; + + const buildMultiColumnTree = (allRows: DataTableRow[], columns: number[], level = 0): DataTableTreeNode[] => { + if (level >= columns.length) { + return allRows + .sort((a, b) => a.position - b.position) + .map((row) => ({ type: 'row' as const, id: row.id, label: getRowLabel(row), level, row })); + } + + const columnIndex = columns[level]; + const groups = new Map(); + const rootRows: DataTableRow[] = []; + + for (const row of allRows) { + const vals = Array.isArray(row.values) ? row.values : []; + const key = String(vals[columnIndex] ?? '').trim(); + if (!key) { + rootRows.push(row); + continue; + } + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(row); + } + + const nodes: DataTableTreeNode[] = []; + for (const [label, groupRows] of groups) { + // Если у группы все строки имеют пустое значение в treeDisplayColumns, + // группа становится конечной строкой (можно выбрать в задачах) + const allEmpty = groupRows.every((row) => { + const vals = Array.isArray(row.values) ? row.values : []; + return treeDisplayColumns.every((ci) => !String(vals[ci] ?? '').trim()); + }); + + if (allEmpty && groupRows.length === 1) { + nodes.push({ + type: 'row', + id: groupRows[0].id, + label, + level, + row: groupRows[0], + }); + } else { + nodes.push({ + type: 'group', + id: `group:${level}:${label}`, + label, + level, + children: buildMultiColumnTree(groupRows, columns, level + 1), + }); + } + } + + if (rootRows.length > 0) { + nodes.push(...buildMultiColumnTree(rootRows, columns, level + 1)); + } + + return nodes.sort((a, b) => { + const al = a.type === 'group' ? a.label : getRowLabel(a.row); + const bl = b.type === 'group' ? b.label : getRowLabel(b.row); + return al.localeCompare(bl, 'ru'); + }); + }; + + const filterNodes = (nodes: DataTableTreeNode[], query: string): DataTableTreeNode[] => { + if (!query) return nodes; + const result: DataTableTreeNode[] = []; + for (const node of nodes) { + if (node.type === 'row') { + const label = node.label.toLowerCase(); + if (label.includes(query)) result.push(node); + } else { + const children = filterNodes(node.children, query); + if (children.length > 0) { + result.push({ ...node, children }); + } else if (node.label.toLowerCase().includes(query)) { + result.push({ ...node, children: [] }); + } + } + } + return result; + }; + + const rawNodes = treeColumns.length > 0 + ? buildMultiColumnTree(filteredRows, treeColumns) + : buildParentTree(filteredRows, null); + const nodes = search ? filterNodes(rawNodes, search) : rawNodes; + + return { + columnIndex: treeColumnIndex, + treeColumnIndex, + treeColumns, + treeDisplayColumns, + displayColumnIndex, + nodes, + }; +}