Files
iistwin/server/storage/data-tables-core.storage.ts
Ильяс Султанов 9528a8123a perf(directories): SQL-фильтрация/пагинация справочников, N+1, лимиты column-values
Шаги 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)
2026-09-07 23:09:15 +03:00

830 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }[]) || [];
}
}