Шаги 1.1 и 1.2 плана production-готовности: - getDataTableRowsPaged: фильтры/поиск/сортировка/пагинация в SQL (values->>N) - getDataTablesWithAccess и list_directories без N+1 - /column-values: limit (дефолт 20, кап 1000), убраны debug-логи с горячего пути - tree-режим и legacy-путь TableEditor сохранены 1:1 - 10 новых тестов (88/88)
830 lines
28 KiB
TypeScript
830 lines
28 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, 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<number, string>;
|
||
search?: string;
|
||
sortColumn?: number;
|
||
sortDirection?: 'asc' | 'desc';
|
||
limit?: number;
|
||
offset?: number;
|
||
}
|
||
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[]> {
|
||
// Права пользователя собираем одним запросом, чтобы не делать 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<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);
|
||
}
|
||
|
||
/**
|
||
* 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<number>`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<Map<number, number>> {
|
||
const result = await db
|
||
.select({
|
||
tableId: dataTableRows.tableId,
|
||
count: sql<number>`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<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');
|
||
}
|
||
}
|
||
|
||
// Только 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<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[]; parentId?: number | null; position?: number }[]): Promise<DataTableRow[]> {
|
||
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<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 }[]) || [];
|
||
}
|
||
}
|