Files
iistwin/server/storage/data-tables-core.storage.ts

721 lines
24 KiB
TypeScript

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, desc, sql, inArray } from "drizzle-orm";
import { BotsStorage } from "./bots.storage";
export class DataTablesCoreStorage extends BotsStorage {
async getDataTable(id: number, organizationId: number): Promise<DataTable | undefined> {
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<DataTable[]> {
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<DataTable[]> {
const allTables = await db
.select()
.from(dataTables)
.where(and(
eq(dataTables.organizationId, organizationId),
eq(dataTables.isDeleted, false)
))
.orderBy(desc(dataTables.createdAt));
const tablesWithAccess: DataTable[] = [];
for (const table of allTables) {
if (table.createdBy === userId) {
tablesWithAccess.push(table);
} else {
const [permission] = await db
.select()
.from(dataTablePermissions)
.where(and(
eq(dataTablePermissions.tableId, table.id),
eq(dataTablePermissions.userId, userId)
));
if (permission) {
tablesWithAccess.push(table);
}
}
}
return tablesWithAccess;
}
// =====================================================
// DATA TABLE LINKS (подсправочники)
// =====================================================
async getDataTableLinks(parentTableId: number, organizationId: number): Promise<DataTableSubdirectory[]> {
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<DataTableFull | undefined> {
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<DataTable> {
const [newTable] = await db
.insert(dataTables)
.values(table)
.returning();
return newTable;
}
async updateDataTable(id: number, organizationId: number, updates: Partial<DataTable>): Promise<DataTable> {
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<void> {
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<DataTableRow[]> {
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);
}
async getDataTableRow(id: number, tableId: number, organizationId: number): Promise<DataTableRow | undefined> {
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<DataTableRow> {
const [newRow] = await db
.insert(dataTableRows)
.values(row)
.returning();
return newRow;
}
async updateDataTableRow(id: number, tableId: number, organizationId: number, updates: Partial<DataTableRow>): Promise<DataTableRow> {
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<void> {
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<Map<number, DataTableRow>> {
const rows = await this.getDataTableRows(tableId, organizationId);
const map = new Map<number, DataTableRow>();
for (const row of rows) {
map.set(row.id, row);
}
return map;
}
private getDescendantIds(rowsById: Map<number, DataTableRow>, rootId: number): Set<number> {
const ids = new Set<number>();
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<DataTableRow> {
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');
}
}
const rows = await this.getDataTableRows(tableId, organizationId);
const siblings = rows
.filter((r) => r.id !== id && (r.parentId ?? null) === targetParentId)
.sort((a, b) => a.position - b.position);
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<DataTableRow> = { 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[] }[]): Promise<DataTableRow[]> {
if (rows.length === 0) return [];
const insertData = rows.map(row => ({
tableId,
values: row.values,
}));
return await db
.insert(dataTableRows)
.values(insertData)
.returning();
}
async bulkDeleteDataTableRows(tableId: number, organizationId: number): Promise<void> {
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<DataTableLink | undefined> {
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<DataTableLink> {
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<DataTableLink>): Promise<DataTableLink> {
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<void> {
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<void> {
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<typeof dataTableRows.$inferInsert> = { 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<typeof dataTableRows.$inferInsert> = { 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<DataTablePermission | undefined> {
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<string, number> = { 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<boolean> {
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<DataTablePermission> {
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<void> {
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<DataTableAccessRule[]> {
return db
.select()
.from(dataTableAccessRules)
.where(and(
eq(dataTableAccessRules.tableId, tableId),
eq(dataTableAccessRules.organizationId, organizationId)
));
}
async createDataTableAccessRule(data: InsertDataTableAccessRule): Promise<DataTableAccessRule> {
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<void> {
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 }[]) || [];
}
}