fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
442
server/storage/system.storage.ts
Normal file
442
server/storage/system.storage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user