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,442 @@
import { users, forms } from "@shared/schema";
import { ragSettings, type RagSetting } from "@shared/schema";
import { llmProviders, type LlmProvider, type InsertLlmProvider } from "@shared/schema";
import { taskRoles, type TaskRole, taskUserAccess, type TaskUserAccess, roleMembers, roles, taskAssignees, type TaskAssignee, formAccessRules, type FormAccessRule, type InsertFormAccessRule } from "@shared/schema";
import { db } from "../db";
import { eq, and, asc, isNull, sql, inArray } from "drizzle-orm";
import { RolesStorage } from "./roles.storage";
export class SystemStorage extends RolesStorage {
// =====================
// Task Roles
// =====================
async getTaskRoles(taskId: number, organizationId: number): Promise<Array<TaskRole & { role: { id: number; name: string; parentId: number | null } }>> {
const rows = await db
.select({
id: taskRoles.id,
taskId: taskRoles.taskId,
roleId: taskRoles.roleId,
organizationId: taskRoles.organizationId,
createdAt: taskRoles.createdAt,
role: {
id: roles.id,
name: roles.name,
parentId: roles.parentId,
},
})
.from(taskRoles)
.innerJoin(roles, eq(taskRoles.roleId, roles.id))
.where(and(eq(taskRoles.taskId, taskId), eq(taskRoles.organizationId, organizationId)));
return rows;
}
async addTaskRole(taskId: number, roleId: number, organizationId: number): Promise<TaskRole> {
const existing = await db.select().from(taskRoles)
.where(and(eq(taskRoles.taskId, taskId), eq(taskRoles.roleId, roleId)))
.limit(1);
if (existing.length > 0) return existing[0];
const [row] = await db.insert(taskRoles).values({ taskId, roleId, organizationId }).returning();
return row;
}
async removeTaskRole(taskId: number, roleId: number, organizationId: number): Promise<void> {
await db.delete(taskRoles).where(and(
eq(taskRoles.taskId, taskId),
eq(taskRoles.roleId, roleId),
eq(taskRoles.organizationId, organizationId),
));
}
async addTaskAssignee(taskId: number, userId: number, organizationId: number): Promise<TaskAssignee> {
const existing = await db.select().from(taskAssignees)
.where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, userId)))
.limit(1);
if (existing.length > 0) return existing[0];
const [row] = await db.insert(taskAssignees).values({ taskId, userId, organizationId }).returning();
return row;
}
async removeTaskAssignee(taskId: number, userId: number, organizationId: number): Promise<void> {
await db.delete(taskAssignees).where(and(
eq(taskAssignees.taskId, taskId),
eq(taskAssignees.userId, userId),
eq(taskAssignees.organizationId, organizationId),
));
}
// =====================
// Task User Access
// =====================
async getTaskUserAccess(taskId: number, organizationId: number): Promise<Array<TaskUserAccess & { user: { id: number; firstName: string | null; lastName: string | null; email: string } }>> {
const rows = await db
.select({
id: taskUserAccess.id,
taskId: taskUserAccess.taskId,
userId: taskUserAccess.userId,
organizationId: taskUserAccess.organizationId,
grantedVia: taskUserAccess.grantedVia,
roleId: taskUserAccess.roleId,
statusId: taskUserAccess.statusId,
firstInteractionAt: taskUserAccess.firstInteractionAt,
revokedAt: taskUserAccess.revokedAt,
user: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
email: users.email,
},
})
.from(taskUserAccess)
.innerJoin(users, eq(taskUserAccess.userId, users.id))
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.organizationId, organizationId)))
.orderBy(asc(taskUserAccess.firstInteractionAt));
return rows;
}
async recordTaskInteraction(taskId: number, userId: number, organizationId: number, grantedVia: string, roleId?: number | null): Promise<void> {
const existing = await db.select().from(taskUserAccess)
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)))
.limit(1);
if (existing.length > 0) return;
await db.insert(taskUserAccess).values({
taskId,
userId,
organizationId,
grantedVia,
roleId: roleId ?? null,
}).onConflictDoNothing();
}
async upsertTaskUserAccess(taskId: number, userId: number, organizationId: number, grantedVia: string): Promise<void> {
const existing = await db.select().from(taskUserAccess)
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)))
.limit(1);
if (existing.length > 0) {
if (!existing[0].revokedAt) return;
await db.update(taskUserAccess)
.set({ revokedAt: null, grantedVia })
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)));
} else {
await db.insert(taskUserAccess).values({
taskId,
userId,
organizationId,
grantedVia,
roleId: null,
}).onConflictDoNothing();
}
}
async revokeTaskUserAccess(taskId: number, userId: number, organizationId: number): Promise<void> {
await db.update(taskUserAccess)
.set({ revokedAt: new Date() })
.where(and(
eq(taskUserAccess.taskId, taskId),
eq(taskUserAccess.userId, userId),
eq(taskUserAccess.organizationId, organizationId),
isNull(taskUserAccess.revokedAt),
));
}
async resolveTaskAccessGrant(taskId: number, userId: number, organizationId: number): Promise<{ grantedVia: string; roleId: number | null }> {
const { roles: rolesTable, roleMembers } = await import('@shared/schema');
const assigned = await db.select({ roleId: taskRoles.roleId })
.from(taskRoles)
.where(and(eq(taskRoles.taskId, taskId), eq(taskRoles.organizationId, organizationId)));
if (assigned.length === 0) return { grantedVia: 'direct', roleId: null };
const assignedIds = assigned.map(r => r.roleId);
const direct = await db.select({ roleId: roleMembers.roleId })
.from(roleMembers)
.where(and(eq(roleMembers.userId, userId), inArray(roleMembers.roleId, assignedIds)))
.limit(1);
if (direct.length > 0) return { grantedVia: 'role', roleId: direct[0].roleId };
const rolesWithParents = await db.select({ id: rolesTable.id, parentId: rolesTable.parentId })
.from(rolesTable)
.where(inArray(rolesTable.id, assignedIds));
for (const role of rolesWithParents) {
if (role.parentId !== null) {
const sup = await db.select().from(roleMembers)
.where(and(eq(roleMembers.userId, userId), eq(roleMembers.roleId, role.parentId)))
.limit(1);
if (sup.length > 0) return { grantedVia: 'supervisor', roleId: role.id };
}
}
return { grantedVia: 'direct', roleId: null };
}
async recordTaskInteractionAuto(taskId: number, userId: number, organizationId: number): Promise<void> {
const grant = await this.resolveTaskAccessGrant(taskId, userId, organizationId);
await this.recordTaskInteraction(taskId, userId, organizationId, grant.grantedVia, grant.roleId);
}
async recordTransitionAccess(taskId: number, userId: number, organizationId: number, roleId: number | null, statusId: number): Promise<void> {
const existing = await db.select().from(taskUserAccess)
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)))
.limit(1);
if (existing.length > 0) {
await db.update(taskUserAccess)
.set({ grantedVia: 'transition', roleId: roleId ?? null, statusId })
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)));
} else {
await db.insert(taskUserAccess).values({
taskId,
userId,
organizationId,
grantedVia: 'transition',
roleId: roleId ?? null,
statusId,
}).onConflictDoUpdate({
target: [taskUserAccess.taskId, taskUserAccess.userId],
set: { grantedVia: 'transition', roleId: roleId ?? null, statusId },
});
}
}
/**
* Returns the set of task IDs accessible to the given user within the organization.
* Returns null for admins (all tasks accessible — skip filtering).
*/
async getAccessibleTaskIds(userId: number, organizationId: number, appRole: string): Promise<Set<number> | null> {
const hasViewAll = await this.hasAppRolePermission(appRole, ['tasks.view_all']);
if (hasViewAll) return null;
const result = await db.execute(sql`
SELECT DISTINCT t.id
FROM tasks t
INNER JOIN forms f ON t.form_id = f.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.target_type = 'user' AND far.target_id = ${userId})
OR (far.target_type = 'role' AND EXISTS (
SELECT 1 FROM role_members rm_far
WHERE rm_far.role_id = far.target_id AND rm_far.user_id = ${userId}
))
)
)
)
AND (
-- Задача доступна напрямую
t.created_by = ${userId}
OR t.assigned_to = ${userId}
OR EXISTS (
SELECT 1 FROM task_assignees ta
WHERE ta.task_id = t.id AND ta.user_id = ${userId}
)
OR (
NOT EXISTS (
SELECT 1 FROM task_user_access tua_rev
WHERE tua_rev.task_id = t.id
AND tua_rev.user_id = ${userId}
AND tua_rev.revoked_at IS NOT NULL
)
AND (
EXISTS (
SELECT 1 FROM task_user_access tua
WHERE tua.task_id = t.id
AND tua.user_id = ${userId}
AND tua.revoked_at IS NULL
)
OR
EXISTS (
SELECT 1 FROM task_roles tr2
INNER JOIN role_members rm ON rm.role_id = tr2.role_id
WHERE tr2.task_id = t.id
AND tr2.organization_id = ${organizationId}
AND rm.user_id = ${userId}
)
OR
EXISTS (
SELECT 1 FROM task_roles tr3
INNER JOIN roles r ON r.id = tr3.role_id AND r.parent_id IS NOT NULL
INNER JOIN role_members rm2 ON rm2.role_id = r.parent_id
WHERE tr3.task_id = t.id
AND tr3.organization_id = ${organizationId}
AND rm2.user_id = ${userId}
)
)
)
-- Если у задачи нет ролей — доступна всем, кто имеет доступ к форме
OR NOT EXISTS (
SELECT 1 FROM task_roles tr
WHERE tr.task_id = t.id AND tr.organization_id = ${organizationId}
)
)
`);
const ids = new Set<number>();
for (const row of result.rows as { id: number }[]) {
ids.add(Number(row.id));
}
return ids;
}
// LLM Providers
async getLlmProviders(organizationId: number): Promise<LlmProvider[]> {
return db.select().from(llmProviders).where(eq(llmProviders.organizationId, organizationId)).orderBy(asc(llmProviders.createdAt));
}
async getLlmProvider(id: number, organizationId: number): Promise<LlmProvider | undefined> {
const [row] = await db.select().from(llmProviders).where(and(eq(llmProviders.id, id), eq(llmProviders.organizationId, organizationId)));
return row || undefined;
}
async createLlmProvider(data: InsertLlmProvider): Promise<LlmProvider> {
const [row] = await db.insert(llmProviders).values(data).returning();
return row;
}
async updateLlmProvider(id: number, organizationId: number, updates: Partial<Omit<LlmProvider, 'id' | 'organizationId' | 'createdAt'>>): Promise<LlmProvider> {
const [row] = await db
.update(llmProviders)
.set({ ...updates, updatedAt: new Date() })
.where(and(eq(llmProviders.id, id), eq(llmProviders.organizationId, organizationId)))
.returning();
return row;
}
async deleteLlmProvider(id: number, organizationId: number): Promise<void> {
await db.delete(llmProviders).where(and(eq(llmProviders.id, id), eq(llmProviders.organizationId, organizationId)));
}
// RAG Settings
async getRagSettings(organizationId: number): Promise<RagSetting | undefined> {
const [row] = await db.select().from(ragSettings).where(eq(ragSettings.organizationId, organizationId));
return row || undefined;
}
async upsertRagSettings(organizationId: number, updates: Partial<Omit<RagSetting, 'organizationId' | 'updatedAt'>>): Promise<RagSetting> {
const [row] = await db
.insert(ragSettings)
.values({ organizationId, ...updates })
.onConflictDoUpdate({
target: ragSettings.organizationId,
set: { ...updates, updatedAt: new Date() },
})
.returning();
return row;
}
async canUserAccessTask(taskId: number, userId: number, organizationId: number, appRole: string): Promise<boolean> {
const hasViewAll = await this.hasAppRolePermission(appRole, ['tasks.view_all']);
if (hasViewAll) return true;
const task = await this.getTask(taskId, organizationId);
if (!task) return false;
if (task.createdBy === userId || task.assignedTo === userId) return true;
// task_assignees
const { taskAssignees } = await import('@shared/schema');
const assigneeRecord = await db.select()
.from(taskAssignees)
.where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, userId)))
.limit(1);
if (assigneeRecord.length > 0) return true;
const accessRecord = await db.select()
.from(taskUserAccess)
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)))
.limit(1);
if (accessRecord.length > 0 && accessRecord[0].revokedAt !== null) return false;
if (accessRecord.length > 0 && accessRecord[0].revokedAt === null) return true;
const { roleMembers, roles: rolesTable } = await import('@shared/schema');
const assigned = await db.select({ roleId: taskRoles.roleId })
.from(taskRoles)
.where(and(eq(taskRoles.taskId, taskId), eq(taskRoles.organizationId, organizationId)));
const assignedIds = assigned.map(r => r.roleId);
if (assignedIds.length === 0) {
// Если у задачи нет ролей — доступ через форму
return await this.canUserAccessForm(userId, task.formId, organizationId, 'participate');
}
const direct = await db.select().from(roleMembers)
.where(and(eq(roleMembers.userId, userId), inArray(roleMembers.roleId, assignedIds)))
.limit(1);
if (direct.length > 0) return true;
const rolesWithParents = await db.select({ id: rolesTable.id, parentId: rolesTable.parentId })
.from(rolesTable)
.where(inArray(rolesTable.id, assignedIds));
const parentIds = rolesWithParents.filter(r => r.parentId !== null).map(r => r.parentId as number);
if (parentIds.length > 0) {
const sup = await db.select().from(roleMembers)
.where(and(eq(roleMembers.userId, userId), inArray(roleMembers.roleId, parentIds)))
.limit(1);
if (sup.length > 0) return true;
}
return false;
}
// =====================
// Form Access Rules
// =====================
async getFormAccessRules(formId: number, organizationId: number) {
return db.select().from(formAccessRules)
.where(and(eq(formAccessRules.formId, formId), eq(formAccessRules.organizationId, organizationId)))
.orderBy(formAccessRules.createdAt);
}
async createFormAccessRule(data: InsertFormAccessRule) {
const [row] = await db.insert(formAccessRules).values(data).returning();
return row;
}
async updateFormAccessRule(id: number, organizationId: number, updates: { accessLevel?: string }) {
const [row] = await db.update(formAccessRules)
.set(updates)
.where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId)))
.returning();
return row;
}
async deleteFormAccessRule(id: number, organizationId: number) {
await db.delete(formAccessRules)
.where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId)));
}
async transferFormOwnership(formId: number, organizationId: number, newAuthorId: number) {
await db.update(forms)
.set({ createdBy: newAuthorId })
.where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId)));
}
async canUserAccessForm(
userId: number,
formId: number,
organizationId: number,
requiredLevel: 'participate' | 'view_all' | 'admin' = 'participate'
): Promise<boolean> {
const form = await this.getForm(formId, organizationId);
if (!form) return false;
// Автор всегда имеет полный доступ
if (form.createdBy === userId) return true;
// Если форма открыта для всей организации — доступ есть (для view)
if (form.visibility === 'organization' && requiredLevel !== 'admin') return true;
// Проверяем правила доступа
const rules = await db.select().from(formAccessRules)
.where(and(eq(formAccessRules.formId, formId), eq(formAccessRules.organizationId, organizationId)));
const levelRank: Record<string, number> = { participate: 1, view_all: 2, admin: 3 };
const requiredRank = levelRank[requiredLevel] ?? 1;
for (const rule of rules) {
let matches = false;
if (rule.targetType === 'user' && rule.targetId === userId) {
matches = true;
} else if (rule.targetType === 'role') {
const { roleMembers } = await import('@shared/schema');
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 && (levelRank[rule.accessLevel] ?? 0) >= requiredRank) {
return true;
}
}
return false;
}
}