- server/storage/data-tables-core.storage.ts: support parentId in sync operations - server/routes/data-tables-sync.routes.ts: validate parent references and cycles for hierarchical rows - client/TableEditor.tsx: export/import _id/_parent_id/_position, add Download Template button
509 lines
22 KiB
TypeScript
509 lines
22 KiB
TypeScript
import { storage } from "../storage";
|
|
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
|
import { tenantIsolation } from "../middleware/tenant.middleware";
|
|
import { db } from "../db";
|
|
import { dataTables } from "@shared/schema";
|
|
import { eq, and } from "drizzle-orm";
|
|
|
|
export function registerDataTableSyncRoutes(app: import("express").Express): void {
|
|
// =====================================================
|
|
// DATA TABLE SYNC API (для импорта/синхронизации)
|
|
// =====================================================
|
|
|
|
// Sync table (полная синхронизация как в Pyrus)
|
|
app.post(['/api/tables/:tableId/sync', '/api/directories/:tableId/sync'],
|
|
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 { apply, rows } = req.body;
|
|
if (!rows || !Array.isArray(rows)) {
|
|
return res.status(400).json({ error: 'Строки обязательны' });
|
|
}
|
|
|
|
const currentRows = await storage.getDataTableRows(tableId, req.organizationId!);
|
|
const validCurrentRows = currentRows.filter(r => Array.isArray(r.values));
|
|
const currentRowsById = new Map(validCurrentRows.map(r => [r.id, r]));
|
|
|
|
const normalizeValues = (values: (string | null)[] | null, 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;
|
|
};
|
|
|
|
const columnCount = table.columns?.length || 6;
|
|
const makeValuesKey = (values: (string | null)[] | null) => JSON.stringify(normalizeValues(values, columnCount));
|
|
|
|
// Detect extended format: rows contain id or parentId
|
|
const isExtendedFormat = rows.some((r: any) => r && (r.id != null || r.parentId != null));
|
|
|
|
if (isExtendedFormat) {
|
|
// Parse incoming rows
|
|
const incomingRows: {
|
|
rawId?: number | null;
|
|
values: string[];
|
|
parentId?: number | null;
|
|
position: number;
|
|
}[] = rows.map((r: any, index: number) => ({
|
|
rawId: r.id != null ? Number(r.id) : undefined,
|
|
values: normalizeValues(r.values, columnCount),
|
|
parentId: r.parentId != null ? Number(r.parentId) : null,
|
|
position: index,
|
|
}));
|
|
|
|
// Resolve existing row id for each incoming row
|
|
const incomingWithResolvedId = incomingRows.map((row) => {
|
|
const existing = row.rawId != null ? currentRowsById.get(row.rawId) : undefined;
|
|
return {
|
|
...row,
|
|
existingId: existing ? existing.id : undefined,
|
|
};
|
|
});
|
|
|
|
// Determine deleted rows: existing ids not present in incoming rows
|
|
const incomingExistingIds = new Set(
|
|
incomingWithResolvedId
|
|
.filter(r => r.existingId != null)
|
|
.map(r => r.existingId!)
|
|
);
|
|
const deleted = validCurrentRows
|
|
.filter(r => !incomingExistingIds.has(r.id))
|
|
.map(r => ({ id: r.id, values: normalizeValues(r.values, columnCount) }));
|
|
const deletedIds = new Set(deleted.map(d => d.id));
|
|
|
|
// Validate parent references
|
|
const validParentIds = new Set(validCurrentRows.map(r => r.id).filter(id => !deletedIds.has(id)));
|
|
for (const row of incomingWithResolvedId) {
|
|
if (row.parentId != null) {
|
|
if (!validParentIds.has(row.parentId)) {
|
|
return res.status(400).json({ error: `Некорректный parentId ${row.parentId}: строка не найдена или будет удалена` });
|
|
}
|
|
if (row.existingId != null && row.parentId === row.existingId) {
|
|
return res.status(400).json({ error: `Строка не может быть родителем самой себя (id ${row.existingId})` });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Detect cycles using union-find / ancestor check
|
|
const parentMap = new Map<number | string, number | null>();
|
|
for (const row of incomingWithResolvedId) {
|
|
const key = row.existingId != null ? row.existingId : `new:${row.position}`;
|
|
parentMap.set(key, row.parentId ?? null);
|
|
}
|
|
|
|
const getAncestors = (start: number | string): Set<number | string> => {
|
|
const ancestors = new Set<number | string>();
|
|
let current: number | string | null | undefined = start;
|
|
while (current != null) {
|
|
if (ancestors.has(current)) break; // cycle detected
|
|
ancestors.add(current);
|
|
current = parentMap.get(current);
|
|
}
|
|
return ancestors;
|
|
};
|
|
|
|
for (const row of incomingWithResolvedId) {
|
|
if (row.parentId == null) continue;
|
|
const key = row.existingId != null ? row.existingId : `new:${row.position}`;
|
|
const ancestors = getAncestors(row.parentId);
|
|
if (ancestors.has(key)) {
|
|
return res.status(400).json({ error: `Обнаружен цикл в иерархии для строки ${row.values[0] || row.position}` });
|
|
}
|
|
}
|
|
|
|
// Build preview lists
|
|
const added: { values: string[]; position: number; parentId: number | null }[] = [];
|
|
const updated: { id: number; values: string[]; oldPosition: number; newPosition: number; parentId: number | null; oldParentId: number | null }[] = [];
|
|
|
|
for (const row of incomingWithResolvedId) {
|
|
if (row.existingId == null) {
|
|
added.push({ values: row.values, position: row.position, parentId: row.parentId ?? null });
|
|
} else {
|
|
const existing = currentRowsById.get(row.existingId)!;
|
|
const parentChanged = (row.parentId ?? null) !== (existing.parentId ?? null);
|
|
const positionChanged = row.position !== existing.position;
|
|
const valuesChanged = makeValuesKey(row.values) !== makeValuesKey(existing.values);
|
|
if (parentChanged || positionChanged || valuesChanged) {
|
|
updated.push({
|
|
id: existing.id,
|
|
values: row.values,
|
|
oldPosition: existing.position,
|
|
newPosition: row.position,
|
|
parentId: row.parentId ?? null,
|
|
oldParentId: existing.parentId ?? null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (apply) {
|
|
const positionUpdates = updated
|
|
.filter(u => u.oldPosition === u.newPosition && u.oldParentId === u.parentId && makeValuesKey(u.values) === makeValuesKey(currentRowsById.get(u.id)!.values))
|
|
.map(u => ({ id: u.id, position: u.newPosition, parentId: u.parentId }));
|
|
const reordered = updated
|
|
.filter(u => u.oldPosition !== u.newPosition || u.oldParentId !== u.parentId || makeValuesKey(u.values) !== makeValuesKey(currentRowsById.get(u.id)!.values))
|
|
.map(u => ({ id: u.id, newPosition: u.newPosition, parentId: u.parentId }));
|
|
|
|
await storage.applyDataTableSync(
|
|
tableId,
|
|
deleted,
|
|
added,
|
|
reordered,
|
|
positionUpdates
|
|
);
|
|
}
|
|
|
|
return res.json({
|
|
apply,
|
|
added: added.length,
|
|
updated: updated.length,
|
|
deleted: deleted.length,
|
|
addedRows: added.map(r => ({ values: r.values, parentId: r.parentId })),
|
|
updatedRows: updated.map(r => ({ id: r.id, values: r.values, oldPosition: r.oldPosition, newPosition: r.newPosition, parentId: r.parentId, oldParentId: r.oldParentId })),
|
|
deletedRows: deleted.map(r => ({ id: r.id, values: r.values })),
|
|
});
|
|
}
|
|
|
|
// Legacy format: sync by full values key
|
|
const currentRowsByKey = new Map(validCurrentRows.map(r => [makeValuesKey(r.values), r]));
|
|
const currentKeysSet = new Set(validCurrentRows.map(r => makeValuesKey(r.values)));
|
|
const newKeysSet = new Set(rows.map((r: { values: string[] }) => makeValuesKey(r.values)));
|
|
|
|
const added: { values: string[], position: number; parentId?: number | null }[] = [];
|
|
const deleted: { id: number; values: string[] }[] = [];
|
|
const reordered: { id: number; values: string[]; oldPosition: number; newPosition: number; parentId?: number | null }[] = [];
|
|
|
|
rows.forEach((newRow: { values: string[] }, index: number) => {
|
|
const key = makeValuesKey(newRow.values);
|
|
if (!currentKeysSet.has(key)) {
|
|
added.push({ values: newRow.values, position: index });
|
|
} else {
|
|
const existingRow = currentRowsByKey.get(key);
|
|
if (existingRow && existingRow.position !== index) {
|
|
reordered.push({
|
|
id: existingRow.id,
|
|
values: normalizeValues(existingRow.values, columnCount),
|
|
oldPosition: existingRow.position,
|
|
newPosition: index,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
for (const currentRow of validCurrentRows) {
|
|
const key = makeValuesKey(currentRow.values);
|
|
if (!newKeysSet.has(key)) {
|
|
deleted.push({ id: currentRow.id, values: normalizeValues(currentRow.values, columnCount) });
|
|
}
|
|
}
|
|
|
|
if (apply) {
|
|
const positionUpdates: { id: number; position: number }[] = [];
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const key = makeValuesKey(rows[i].values);
|
|
const existingRow = currentRowsByKey.get(key);
|
|
if (existingRow && !deleted.some(d => d.id === existingRow.id)) {
|
|
positionUpdates.push({ id: existingRow.id, position: i });
|
|
}
|
|
}
|
|
await storage.applyDataTableSync(
|
|
tableId,
|
|
deleted,
|
|
added,
|
|
reordered.map(r => ({ id: r.id, newPosition: r.newPosition })),
|
|
positionUpdates
|
|
);
|
|
}
|
|
|
|
res.json({
|
|
apply,
|
|
added: added.length,
|
|
deleted: deleted.length,
|
|
reordered: reordered.length,
|
|
addedRows: added.map(r => ({ values: r.values })),
|
|
deletedRows: deleted.map(r => ({ id: r.id, values: r.values })),
|
|
reorderedRows: reordered.map(r => ({ values: r.values, oldPosition: r.oldPosition, newPosition: r.newPosition }))
|
|
});
|
|
} catch (error) {
|
|
console.error('Sync table error:', error);
|
|
res.status(500).json({ error: 'Ошибка синхронизации таблицы' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Diff table (частичное обновление как в Pyrus)
|
|
app.post(['/api/tables/:tableId/diff', '/api/directories/:tableId/diff'],
|
|
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 { upsert, delete: deleteKeys } = req.body;
|
|
|
|
const currentRows = await storage.getDataTableRows(tableId, req.organizationId!);
|
|
|
|
const upsertArr = upsert && Array.isArray(upsert) ? upsert : [];
|
|
const deleteKeysArr = deleteKeys && Array.isArray(deleteKeys) ? deleteKeys : [];
|
|
|
|
const result = await storage.applyDataTableDiff(tableId, upsertArr, deleteKeysArr, currentRows);
|
|
|
|
res.json({
|
|
apply: true,
|
|
added: result.added.map(r => ({ id: r.id, values: r.values })),
|
|
updated: result.updated.map(r => ({ id: r.id, values: r.values })),
|
|
deleted: result.deleted.map(r => ({ id: r.id, values: r.values }))
|
|
});
|
|
} catch (error) {
|
|
console.error('Diff table error:', error);
|
|
res.status(500).json({ error: 'Ошибка изменения таблицы' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// =====================================================
|
|
// DATA TABLE PERMISSIONS API
|
|
// =====================================================
|
|
|
|
// Get table permissions
|
|
app.get(['/api/tables/:tableId/permissions', '/api/directories/:tableId/permissions'],
|
|
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 !== 'admin') {
|
|
return res.status(403).json({ error: 'Нет прав для просмотра прав доступа' });
|
|
}
|
|
|
|
const permissions = await storage.getDataTablePermissions(tableId, req.organizationId!);
|
|
res.json({ permissions, createdBy: table.createdBy });
|
|
} catch (error) {
|
|
console.error('Get table permissions error:', error);
|
|
res.status(500).json({ error: 'Ошибка получения прав доступа' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set table permission
|
|
app.post(['/api/tables/:tableId/permissions', '/api/directories/:tableId/permissions'],
|
|
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 currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (currentRole !== 'admin') {
|
|
return res.status(403).json({ error: 'Нет прав для управления правами доступа' });
|
|
}
|
|
|
|
const { userId, role } = req.body;
|
|
if (!userId || !role) {
|
|
return res.status(400).json({ error: 'ID пользователя и роль обязательны' });
|
|
}
|
|
|
|
if (!['admin', 'editor', 'reader'].includes(role)) {
|
|
return res.status(400).json({ error: 'Недопустимая роль' });
|
|
}
|
|
|
|
if (userId === table.createdBy) {
|
|
return res.status(400).json({ error: 'Нельзя изменить права создателя таблицы' });
|
|
}
|
|
|
|
const permission = await storage.setDataTablePermission({ tableId, userId, role });
|
|
res.json({ permission });
|
|
} catch (error) {
|
|
console.error('Set table permission error:', error);
|
|
res.status(500).json({ error: 'Ошибка установки прав доступа' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Delete table permission
|
|
app.delete(['/api/tables/:tableId/permissions/:userId', '/api/directories/:tableId/permissions/:userId'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.tableId);
|
|
const targetUserId = parseInt(req.params.userId);
|
|
const table = await storage.getDataTable(tableId, req.organizationId!);
|
|
|
|
if (!table) {
|
|
return res.status(404).json({ error: 'Таблица не найдена' });
|
|
}
|
|
|
|
const currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (currentRole !== 'admin') {
|
|
return res.status(403).json({ error: 'Нет прав для управления правами доступа' });
|
|
}
|
|
|
|
await storage.deleteDataTablePermission(tableId, targetUserId);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Delete table permission error:', error);
|
|
res.status(500).json({ error: 'Ошибка удаления прав доступа' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// =====================================================
|
|
// DATA TABLE ACCESS RULES API (role-based)
|
|
// =====================================================
|
|
|
|
app.get(['/api/tables/:tableId/access-rules', '/api/directories/:tableId/access-rules'],
|
|
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 currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (currentRole !== 'admin') {
|
|
return res.status(403).json({ error: 'Нет прав для просмотра прав доступа' });
|
|
}
|
|
|
|
const rules = await storage.getDataTableAccessRules(tableId, req.organizationId!);
|
|
const linkedForms = await storage.getLinkedFormsForTable(tableId, req.organizationId!);
|
|
res.json({ rules, linkedForms });
|
|
} catch (error) {
|
|
console.error('Get table access rules error:', error);
|
|
res.status(500).json({ error: 'Ошибка получения прав доступа' });
|
|
}
|
|
}
|
|
);
|
|
|
|
app.post(['/api/tables/:tableId/access-rules', '/api/directories/:tableId/access-rules'],
|
|
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 currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (currentRole !== 'admin') {
|
|
return res.status(403).json({ error: 'Нет прав для управления правами доступа' });
|
|
}
|
|
|
|
const { targetType, targetId, accessLevel } = req.body;
|
|
if (!targetType || !targetId || !accessLevel) {
|
|
return res.status(400).json({ error: 'targetType, targetId и accessLevel обязательны' });
|
|
}
|
|
if (!['user', 'role'].includes(targetType) || !['reader', 'editor', 'admin'].includes(accessLevel)) {
|
|
return res.status(400).json({ error: 'Недопустимые значения targetType или accessLevel' });
|
|
}
|
|
|
|
const rule = await storage.createDataTableAccessRule({
|
|
tableId,
|
|
organizationId: req.organizationId!,
|
|
targetType,
|
|
targetId,
|
|
accessLevel,
|
|
});
|
|
res.json({ rule });
|
|
} catch (error) {
|
|
console.error('Create table access rule error:', error);
|
|
res.status(500).json({ error: 'Ошибка создания правила доступа' });
|
|
}
|
|
}
|
|
);
|
|
|
|
app.delete(['/api/tables/:tableId/access-rules/:ruleId', '/api/directories/:tableId/access-rules/:ruleId'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.tableId);
|
|
const ruleId = parseInt(req.params.ruleId);
|
|
const table = await storage.getDataTable(tableId, req.organizationId!);
|
|
if (!table) return res.status(404).json({ error: 'Таблица не найдена' });
|
|
|
|
const currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (currentRole !== 'admin') {
|
|
return res.status(403).json({ error: 'Нет прав для управления правами доступа' });
|
|
}
|
|
|
|
await storage.deleteDataTableAccessRule(ruleId, req.organizationId!);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Delete table access rule error:', error);
|
|
res.status(500).json({ error: 'Ошибка удаления правила доступа' });
|
|
}
|
|
}
|
|
);
|
|
|
|
app.patch(['/api/tables/:tableId/visibility', '/api/directories/:tableId/visibility'],
|
|
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 currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (currentRole !== 'admin') {
|
|
return res.status(403).json({ error: 'Нет прав для изменения настроек' });
|
|
}
|
|
|
|
const { visibility } = req.body;
|
|
if (!['restricted', 'organization'].includes(visibility)) {
|
|
return res.status(400).json({ error: 'Недопустимое значение visibility' });
|
|
}
|
|
|
|
const [updated] = await db.update(dataTables)
|
|
.set({ visibility })
|
|
.where(and(eq(dataTables.id, tableId), eq(dataTables.organizationId, req.organizationId!)))
|
|
.returning();
|
|
res.json({ table: updated });
|
|
} catch (error) {
|
|
console.error('Update table visibility error:', error);
|
|
res.status(500).json({ error: 'Ошибка изменения видимости' });
|
|
}
|
|
}
|
|
);
|
|
}
|