import { users, dataTables, dataTableRows, dataTablePermissions, dataTableAccessRules, dataTableLinks, formFields, forms, formAccessRules, roleMembers, type DataTable, type DataTableRow, type DataTablePermission, type InsertDataTable, type InsertDataTableRow, type InsertDataTablePermission, type DataTableFull, type DataTableAccessRule, type InsertDataTableAccessRule, type DataTableLink, type DataTableSubdirectory } from "@shared/schema"; import { db } from "../db"; import { eq, and, or, desc, sql, inArray, isNull, ne } from "drizzle-orm"; import { BotsStorage } from "./bots.storage"; import { escapeIlikePattern } from "../utils/data-table-rows-query"; // Параметры SQL-пагинации строк справочника (см. getDataTableRowsPaged) export interface DirectoryRowsPageOptions { filters?: Record; search?: string; sortColumn?: number; sortDirection?: 'asc' | 'desc'; limit?: number; offset?: number; } export class DataTablesCoreStorage extends BotsStorage { async getDataTable(id: number, organizationId: number): Promise { const [table] = await db .select() .from(dataTables) .where(and( eq(dataTables.id, id), eq(dataTables.organizationId, organizationId), eq(dataTables.isDeleted, false) )); return table || undefined; } async getDataTablesByOrganization(organizationId: number): Promise { return await db .select() .from(dataTables) .where(and( eq(dataTables.organizationId, organizationId), eq(dataTables.isDeleted, false) )) .orderBy(desc(dataTables.createdAt)); } async getDataTablesWithAccess(userId: number, organizationId: number): Promise { // Права пользователя собираем одним запросом, чтобы не делать N+1 по каждой таблице const permissions = await db .select({ tableId: dataTablePermissions.tableId }) .from(dataTablePermissions) .where(eq(dataTablePermissions.userId, userId)); const permittedIds = permissions.map((p) => p.tableId); const accessCondition = permittedIds.length > 0 ? or(eq(dataTables.createdBy, userId), inArray(dataTables.id, permittedIds)) : eq(dataTables.createdBy, userId); return await db .select() .from(dataTables) .where(and( eq(dataTables.organizationId, organizationId), eq(dataTables.isDeleted, false), accessCondition )) .orderBy(desc(dataTables.createdAt)); } // ===================================================== // DATA TABLE LINKS (подсправочники) // ===================================================== async getDataTableLinks(parentTableId: number, organizationId: number): Promise { const links = await db .select({ id: dataTableLinks.id, organizationId: dataTableLinks.organizationId, parentTableId: dataTableLinks.parentTableId, childTableId: dataTableLinks.childTableId, parentColumnIndex: dataTableLinks.parentColumnIndex, childColumnIndex: dataTableLinks.childColumnIndex, parentRowIds: dataTableLinks.parentRowIds, createdAt: dataTableLinks.createdAt, updatedAt: dataTableLinks.updatedAt, childTable: { id: dataTables.id, name: dataTables.name, columns: dataTables.columns, }, }) .from(dataTableLinks) .innerJoin(dataTables, eq(dataTableLinks.childTableId, dataTables.id)) .where(and( eq(dataTableLinks.parentTableId, parentTableId), eq(dataTableLinks.organizationId, organizationId), eq(dataTables.isDeleted, false) )) .orderBy(dataTableLinks.id); return links as DataTableSubdirectory[]; } async getDataTableFull(id: number, organizationId: number): Promise { const table = await this.getDataTable(id, organizationId); if (!table) return undefined; const rows = await this.getDataTableRows(id, organizationId); const permissions = await this.getDataTablePermissions(id, organizationId); const subdirectories = await this.getDataTableLinks(id, organizationId); return { ...table, rows, permissions, subdirectories, }; } async createDataTable(table: InsertDataTable & { organizationId: number; createdBy: number }): Promise { const [newTable] = await db .insert(dataTables) .values(table) .returning(); return newTable; } async updateDataTable(id: number, organizationId: number, updates: Partial): Promise { const [table] = await db .update(dataTables) .set({ ...updates, updatedAt: new Date() }) .where(and( eq(dataTables.id, id), eq(dataTables.organizationId, organizationId) )) .returning(); return table; } async deleteDataTable(id: number, organizationId: number): Promise { await db .update(dataTables) .set({ isDeleted: true, updatedAt: new Date() }) .where(and( eq(dataTables.id, id), eq(dataTables.organizationId, organizationId) )); } // ===================================================== // DATA TABLE ROWS // ===================================================== async getDataTableRows(tableId: number, organizationId: number): Promise { const [table] = await db .select() .from(dataTables) .where(and( eq(dataTables.id, tableId), eq(dataTables.organizationId, organizationId) )); if (!table) return []; return await db .select() .from(dataTableRows) .where(and( eq(dataTableRows.tableId, tableId), eq(dataTableRows.isDeleted, false) )) .orderBy(dataTableRows.position, dataTableRows.id); } /** * SQL-пагинация строк справочника: фильтры/поиск/сортировка/лимит выполняются * на уровне БД, без выгрузки всех строк в память. * Фильтры и поиск — подстрочное совпадение без учёта регистра (ILIKE), * семантика совпадает с прежней JS-фильтрацией (String(...).toLowerCase().includes). * Сортировка — текстовая по lower(values->>N), пустые значения идут первыми (как '' в JS). */ async getDataTableRowsPaged( tableId: number, organizationId: number, opts: DirectoryRowsPageOptions = {} ): Promise<{ rows: DataTableRow[]; total: number }> { const [table] = await db .select() .from(dataTables) .where(and( eq(dataTables.id, tableId), eq(dataTables.organizationId, organizationId) )); if (!table) return { rows: [], total: 0 }; const conditions = [ eq(dataTableRows.tableId, tableId), eq(dataTableRows.isDeleted, false), ]; if (opts.filters) { for (const [colKey, val] of Object.entries(opts.filters)) { const ci = Number(colKey); if (!Number.isInteger(ci) || ci < 0 || !val) continue; conditions.push( sql`${dataTableRows.values}->>${ci} ILIKE ${'%' + escapeIlikePattern(val) + '%'} ESCAPE '\\'` ); } } if (opts.search) { conditions.push( sql`${dataTableRows.values}::text ILIKE ${'%' + escapeIlikePattern(opts.search) + '%'} ESCAPE '\\'` ); } const where = and(...conditions); const [countRow] = await db .select({ count: sql`count(*)::int` }) .from(dataTableRows) .where(where); const total = Number(countRow?.count ?? 0); const query = db.select().from(dataTableRows).where(where); if (opts.sortColumn !== undefined && (opts.sortDirection === 'asc' || opts.sortDirection === 'desc')) { const ci = opts.sortColumn; const sortExpr = opts.sortDirection === 'desc' ? sql`lower(coalesce(${dataTableRows.values}->>${ci}, '')) DESC` : sql`lower(coalesce(${dataTableRows.values}->>${ci}, '')) ASC`; // Вторичная сортировка по position/id — стабильный порядок при равных значениях query.orderBy(sortExpr, dataTableRows.position, dataTableRows.id); } else { query.orderBy(dataTableRows.position, dataTableRows.id); } if (opts.limit !== undefined && opts.limit > 0) { query.limit(opts.limit); } if (opts.offset !== undefined && opts.offset > 0) { query.offset(opts.offset); } const rows = await query; return { rows, total }; } /** * Количество неудалённых строк по каждому справочнику организации одним запросом * (используется MCP list_directories вместо N+1 выборок строк). */ async getDataTableRowCounts(organizationId: number): Promise> { const result = await db .select({ tableId: dataTableRows.tableId, count: sql`count(*)::int`, }) .from(dataTableRows) .innerJoin(dataTables, eq(dataTableRows.tableId, dataTables.id)) .where(and( eq(dataTables.organizationId, organizationId), eq(dataTableRows.isDeleted, false) )) .groupBy(dataTableRows.tableId); return new Map(result.map((r) => [r.tableId, Number(r.count)])); } async getDataTableRow(id: number, tableId: number, organizationId: number): Promise { const [table] = await db .select() .from(dataTables) .where(and( eq(dataTables.id, tableId), eq(dataTables.organizationId, organizationId) )); if (!table) return undefined; const [row] = await db .select() .from(dataTableRows) .where(and( eq(dataTableRows.id, id), eq(dataTableRows.tableId, tableId), eq(dataTableRows.isDeleted, false) )); return row || undefined; } async createDataTableRow(row: InsertDataTableRow): Promise { const [newRow] = await db .insert(dataTableRows) .values(row) .returning(); return newRow; } async updateDataTableRow(id: number, tableId: number, organizationId: number, updates: Partial): Promise { const [table] = await db .select() .from(dataTables) .where(and( eq(dataTables.id, tableId), eq(dataTables.organizationId, organizationId) )); if (!table) throw new Error('Table not found'); const [row] = await db .update(dataTableRows) .set({ ...updates, updatedAt: new Date() }) .where(and( eq(dataTableRows.id, id), eq(dataTableRows.tableId, tableId) )) .returning(); return row; } async deleteDataTableRow(id: number, tableId: number, organizationId: number): Promise { const [table] = await db .select() .from(dataTables) .where(and( eq(dataTables.id, tableId), eq(dataTables.organizationId, organizationId) )); if (!table) return; await db.transaction(async (tx) => { // Move direct children to root before soft-deleting the row await tx .update(dataTableRows) .set({ parentId: null, updatedAt: new Date() }) .where(and( eq(dataTableRows.parentId, id), eq(dataTableRows.tableId, tableId) )); await tx .update(dataTableRows) .set({ isDeleted: true, updatedAt: new Date() }) .where(and( eq(dataTableRows.id, id), eq(dataTableRows.tableId, tableId) )); }); } private async getRowsMap(tableId: number, organizationId: number): Promise> { const rows = await this.getDataTableRows(tableId, organizationId); const map = new Map(); for (const row of rows) { map.set(row.id, row); } return map; } private getDescendantIds(rowsById: Map, rootId: number): Set { const ids = new Set(); const walk = (parentId: number) => { for (const row of rowsById.values()) { if (row.parentId === parentId) { ids.add(row.id); walk(row.id); } } }; walk(rootId); return ids; } async moveDataTableRow( id: number, tableId: number, organizationId: number, parentId: number | null, position: number ): Promise { const table = await this.getDataTable(tableId, organizationId); if (!table) throw new Error('Table not found'); const row = await this.getDataTableRow(id, tableId, organizationId); if (!row) throw new Error('Row not found'); const targetParentId = parentId ?? null; if (targetParentId !== null) { if (targetParentId === id) { throw new Error('Cannot move row into itself'); } const rowsById = await this.getRowsMap(tableId, organizationId); if (this.getDescendantIds(rowsById, id).has(targetParentId)) { throw new Error('Cannot move row into its descendant'); } } // Только siblings нужного родителя — без полной выборки строк таблицы const siblings = await db .select() .from(dataTableRows) .where(and( eq(dataTableRows.tableId, tableId), eq(dataTableRows.isDeleted, false), ne(dataTableRows.id, id), targetParentId === null ? isNull(dataTableRows.parentId) : eq(dataTableRows.parentId, targetParentId) )) .orderBy(dataTableRows.position, dataTableRows.id); const clampedPosition = Math.max(0, Math.min(position, siblings.length)); const reordered = [ ...siblings.slice(0, clampedPosition), row, ...siblings.slice(clampedPosition), ]; await db.transaction(async (tx) => { for (let i = 0; i < reordered.length; i++) { const updates: Partial = { position: i }; if (reordered[i].id === id) { updates.parentId = targetParentId; } await tx .update(dataTableRows) .set({ ...updates, updatedAt: new Date() }) .where(eq(dataTableRows.id, reordered[i].id)); } }); const movedRow = await this.getDataTableRow(id, tableId, organizationId); if (!movedRow) throw new Error('Row not found after move'); return movedRow; } 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 .insert(dataTableRows) .values(insertData) .returning(); } async bulkDeleteDataTableRows(tableId: number, organizationId: number): Promise { const [table] = await db .select() .from(dataTables) .where(and( eq(dataTables.id, tableId), eq(dataTables.organizationId, organizationId) )); if (!table) return; await db .update(dataTableRows) .set({ isDeleted: true, updatedAt: new Date() }) .where(eq(dataTableRows.tableId, tableId)); } // ===================================================== // DATA TABLE LINKS (подсправочники) // ===================================================== async getDataTableLink(id: number, organizationId: number): Promise { const [link] = await db .select() .from(dataTableLinks) .where(and( eq(dataTableLinks.id, id), eq(dataTableLinks.organizationId, organizationId) )); return link || undefined; } async createDataTableLink(data: { organizationId: number; parentTableId: number; childTableId: number; parentColumnIndex?: number; childColumnIndex?: number; parentRowIds?: number[] }): Promise { const [link] = await db .insert(dataTableLinks) .values({ organizationId: data.organizationId, parentTableId: data.parentTableId, childTableId: data.childTableId, parentColumnIndex: data.parentColumnIndex ?? 0, childColumnIndex: data.childColumnIndex ?? 0, parentRowIds: data.parentRowIds ?? [], }) .onConflictDoUpdate({ target: [dataTableLinks.parentTableId, dataTableLinks.childTableId], set: { parentColumnIndex: data.parentColumnIndex ?? 0, childColumnIndex: data.childColumnIndex ?? 0, parentRowIds: data.parentRowIds ?? [], updatedAt: new Date(), }, }) .returning(); return link; } async updateDataTableLink(id: number, organizationId: number, updates: Partial): Promise { const [link] = await db .update(dataTableLinks) .set({ ...updates, updatedAt: new Date() }) .where(and( eq(dataTableLinks.id, id), eq(dataTableLinks.organizationId, organizationId) )) .returning(); return link; } async deleteDataTableLink(id: number, organizationId: number): Promise { await db .delete(dataTableLinks) .where(and( eq(dataTableLinks.id, id), eq(dataTableLinks.organizationId, organizationId) )); } async applyDataTableSync( tableId: number, deleted: { id: number }[], added: { values: string[]; position: number; parentId?: number | null }[], reordered: { id: number; newPosition: number; parentId?: number | null }[], positionUpdates: { id: number; position: number; parentId?: number | null }[] ): Promise { await db.transaction(async (tx) => { for (const row of deleted) { await tx .update(dataTableRows) .set({ isDeleted: true, updatedAt: new Date() }) .where(and(eq(dataTableRows.id, row.id), eq(dataTableRows.tableId, tableId))); } for (const row of added) { await tx .insert(dataTableRows) .values({ tableId, values: row.values, position: row.position, parentId: row.parentId ?? null }); } for (const row of reordered) { const updates: Partial = { position: row.newPosition, updatedAt: new Date() }; if (row.parentId !== undefined) updates.parentId = row.parentId; await tx .update(dataTableRows) .set(updates) .where(and(eq(dataTableRows.id, row.id), eq(dataTableRows.tableId, tableId))); } for (const row of positionUpdates) { const updates: Partial = { position: row.position, updatedAt: new Date() }; if (row.parentId !== undefined) updates.parentId = row.parentId; await tx .update(dataTableRows) .set(updates) .where(and(eq(dataTableRows.id, row.id), eq(dataTableRows.tableId, tableId))); } }); } async applyDataTableDiff( tableId: number, upsertRows: { values: string[] }[], deleteKeys: string[], currentRows: DataTableRow[] ): Promise<{ added: DataTableRow[]; updated: DataTableRow[]; deleted: DataTableRow[] }> { const added: DataTableRow[] = []; const updated: DataTableRow[] = []; const deleted: DataTableRow[] = []; await db.transaction(async (tx) => { const currentKeyMap = new Map(currentRows.map(r => [r.values?.[0] ?? '', r])); for (const row of upsertRows) { const key = row.values[0] ?? ''; const existing = currentKeyMap.get(key); if (existing) { const [updatedRow] = await tx .update(dataTableRows) .set({ values: row.values, updatedAt: new Date() }) .where(and(eq(dataTableRows.id, existing.id), eq(dataTableRows.tableId, tableId))) .returning(); if (updatedRow) updated.push(updatedRow); } else { const [newRow] = await tx .insert(dataTableRows) .values({ tableId, values: row.values }) .returning(); added.push(newRow); } } for (const key of deleteKeys) { const existing = currentKeyMap.get(key); if (existing) { await tx .update(dataTableRows) .set({ isDeleted: true, updatedAt: new Date() }) .where(and(eq(dataTableRows.id, existing.id), eq(dataTableRows.tableId, tableId))); deleted.push(existing); } } }); return { added, updated, deleted }; } // ===================================================== // DATA TABLE PERMISSIONS // ===================================================== async getDataTablePermissions(tableId: number, organizationId: number): Promise<(DataTablePermission & { user: { id: number; firstName: string; lastName: string; email: string } })[]> { const [table] = await db .select() .from(dataTables) .where(and( eq(dataTables.id, tableId), eq(dataTables.organizationId, organizationId) )); if (!table) return []; const permissions = await db .select({ id: dataTablePermissions.id, tableId: dataTablePermissions.tableId, userId: dataTablePermissions.userId, role: dataTablePermissions.role, createdAt: dataTablePermissions.createdAt, user: { id: users.id, firstName: users.firstName, lastName: users.lastName, email: users.email, } }) .from(dataTablePermissions) .innerJoin(users, eq(dataTablePermissions.userId, users.id)) .where(eq(dataTablePermissions.tableId, tableId)); return permissions; } async getDataTablePermission(tableId: number, userId: number): Promise { const [permission] = await db .select() .from(dataTablePermissions) .where(and( eq(dataTablePermissions.tableId, tableId), eq(dataTablePermissions.userId, userId) )); return permission || undefined; } async resolveDataTableAccessRules(tableId: number, userId: number, organizationId: number): Promise<'admin' | 'editor' | 'reader' | null> { const rules = await db .select() .from(dataTableAccessRules) .where(and( eq(dataTableAccessRules.tableId, tableId), eq(dataTableAccessRules.organizationId, organizationId) )); if (rules.length === 0) return null; const levelRank: Record = { reader: 1, editor: 2, admin: 3 }; let bestLevel: string | null = null; let bestRank = 0; for (const rule of rules) { let matches = false; if (rule.targetType === 'user' && rule.targetId === userId) { matches = true; } else if (rule.targetType === 'role') { const member = await db .select() .from(roleMembers) .where(and(eq(roleMembers.userId, userId), eq(roleMembers.roleId, rule.targetId))) .limit(1); if (member.length > 0) matches = true; } if (matches) { const rank = levelRank[rule.accessLevel] ?? 0; if (rank > bestRank) { bestRank = rank; bestLevel = rule.accessLevel; } } } return bestLevel as 'admin' | 'editor' | 'reader' | null; } async hasAccessToFormsUsingTable(tableId: number, userId: number, organizationId: number): Promise { const result = await db.execute(sql` WITH linked_forms AS ( SELECT DISTINCT form_id FROM form_fields WHERE type = 'table' AND (options->>'tableId')::int = ${tableId} ) SELECT EXISTS ( SELECT 1 FROM linked_forms lf JOIN forms f ON f.id = lf.form_id WHERE f.organization_id = ${organizationId} AND ( f.visibility = 'organization' OR f.created_by = ${userId} OR EXISTS ( SELECT 1 FROM form_access_rules far WHERE far.form_id = f.id AND far.organization_id = ${organizationId} AND far.access_level IN ('participate', 'view_all', 'admin') AND ( (far.target_type = 'user' AND far.target_id = ${userId}) OR ( far.target_type = 'role' AND EXISTS ( SELECT 1 FROM role_members rm WHERE rm.role_id = far.target_id AND rm.user_id = ${userId} ) ) ) ) ) ) AS has_access `); const row = (result.rows as { has_access: boolean }[])[0]; return row?.has_access ?? false; } async getUserTableRole(tableId: number, userId: number, createdBy: number, organizationId?: number): Promise<'admin' | 'editor' | 'reader' | null> { if (userId === createdBy) return 'admin'; if (organizationId) { const table = await this.getDataTable(tableId, organizationId); if (table?.visibility === 'organization') return 'reader'; } const permission = await this.getDataTablePermission(tableId, userId); if (permission) return permission.role as 'admin' | 'editor' | 'reader'; if (organizationId) { const ruleAccess = await this.resolveDataTableAccessRules(tableId, userId, organizationId); if (ruleAccess) return ruleAccess; const hasFormAccess = await this.hasAccessToFormsUsingTable(tableId, userId, organizationId); if (hasFormAccess) return 'reader'; } return null; } async setDataTablePermission(permission: InsertDataTablePermission): Promise { const [existingPermission] = await db .select() .from(dataTablePermissions) .where(and( eq(dataTablePermissions.tableId, permission.tableId), eq(dataTablePermissions.userId, permission.userId) )); if (existingPermission) { const [updated] = await db .update(dataTablePermissions) .set({ role: permission.role }) .where(eq(dataTablePermissions.id, existingPermission.id)) .returning(); return updated; } const [newPermission] = await db .insert(dataTablePermissions) .values(permission) .returning(); return newPermission; } async deleteDataTablePermission(tableId: number, userId: number): Promise { await db .delete(dataTablePermissions) .where(and( eq(dataTablePermissions.tableId, tableId), eq(dataTablePermissions.userId, userId) )); } // Data table access rules (role-based) async getDataTableAccessRules(tableId: number, organizationId: number): Promise { return db .select() .from(dataTableAccessRules) .where(and( eq(dataTableAccessRules.tableId, tableId), eq(dataTableAccessRules.organizationId, organizationId) )); } async createDataTableAccessRule(data: InsertDataTableAccessRule): Promise { const [rule] = await db .insert(dataTableAccessRules) .values(data) .onConflictDoUpdate({ target: [dataTableAccessRules.tableId, dataTableAccessRules.targetType, dataTableAccessRules.targetId], set: { accessLevel: data.accessLevel }, }) .returning(); return rule; } async deleteDataTableAccessRule(id: number, organizationId: number): Promise { await db .delete(dataTableAccessRules) .where(and( eq(dataTableAccessRules.id, id), eq(dataTableAccessRules.organizationId, organizationId) )); } async getLinkedFormsForTable(tableId: number, organizationId: number): Promise<{ id: number; name: string }[]> { const result = await db.execute(sql` SELECT DISTINCT f.id, f.name FROM form_fields ff JOIN forms f ON f.id = ff.form_id WHERE ff.type = 'table' AND (ff.options->>'tableId')::int = ${tableId} AND f.organization_id = ${organizationId} `); return (result.rows as { id: number; name: string }[]) || []; } }