fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,531 @@
import { users, dataTables, dataTableRows, dataTablePermissions, dataTableAccessRules, formFields, forms, formAccessRules, roleMembers, type DataTable, type DataTableRow, type DataTablePermission, type InsertDataTable, type InsertDataTableRow, type InsertDataTablePermission, type DataTableFull, type DataTableAccessRule, type InsertDataTableAccessRule } 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;
}
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);
return {
...table,
rows,
permissions,
};
}
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
.update(dataTableRows)
.set({ isDeleted: true, updatedAt: new Date() })
.where(and(
eq(dataTableRows.id, id),
eq(dataTableRows.tableId, tableId)
));
}
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));
}
async applyDataTableSync(
tableId: number,
deleted: { id: number }[],
added: { values: string[]; position: number }[],
reordered: { id: number; newPosition: number }[],
positionUpdates: { id: number; position: number }[]
): 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 });
}
for (const row of reordered) {
await tx
.update(dataTableRows)
.set({ position: row.newPosition, updatedAt: new Date() })
.where(and(eq(dataTableRows.id, row.id), eq(dataTableRows.tableId, tableId)));
}
for (const row of positionUpdates) {
await tx
.update(dataTableRows)
.set({ position: row.position, updatedAt: new Date() })
.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 }[]) || [];
}
}