import { users, forms } from "@shared/schema"; import { ragSettings, type RagSetting } from "@shared/schema"; import { embeddingQueue } 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 { accessibleTasksCache, accessibleTasksKey, invalidateAccessibleTasksForUser, invalidateAccessibleTasksForOrg, } from "../utils/cache"; import { RolesStorage } from "./roles.storage"; export class SystemStorage extends RolesStorage { // ===================== // Task Roles // ===================== async getTaskRoles(taskId: number, organizationId: number): Promise> { 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 { 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(); // Роль задачи меняет доступ всех членов роли (и ветку «нет ролей — доступ всем») invalidateAccessibleTasksForOrg(organizationId); return row; } async removeTaskRole(taskId: number, roleId: number, organizationId: number): Promise { await db.delete(taskRoles).where(and( eq(taskRoles.taskId, taskId), eq(taskRoles.roleId, roleId), eq(taskRoles.organizationId, organizationId), )); invalidateAccessibleTasksForOrg(organizationId); } async addTaskAssignee( taskId: number, userId: number, organizationId: number, options?: { delegatedFromUserId?: number | null; pendingReview?: boolean } ): Promise { const existing = await db.select().from(taskAssignees) .where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, userId))) .limit(1); if (existing.length > 0) { // Явный запрос «на ознакомление» — проставляем флаг на существующей строке if (options?.pendingReview && !existing[0].pendingReview) { const [updated] = await db.update(taskAssignees) .set({ pendingReview: true }) .where(eq(taskAssignees.id, existing[0].id)) .returning(); return updated; } return existing[0]; } const [row] = await db.insert(taskAssignees).values({ taskId, userId, organizationId, delegatedFromUserId: options?.delegatedFromUserId ?? null, pendingReview: options?.pendingReview ?? false, }).returning(); // Новый исполнитель получает доступ к задаче — сбрасываем его кэш доступа invalidateAccessibleTasksForUser(userId, organizationId); // Хук замещения: прямое назначение резолвит активного заместителя. // Пропускаем строки заместителей/ознакомления — иначе получили бы рекурсию. // Динамический import разрывает цикл storage → delegation.service → storage. if (!options?.delegatedFromUserId && !options?.pendingReview) { import("../services/delegation.service") .then((m) => m.applyDelegationsOnAssign(taskId, userId, organizationId)) .catch((err) => console.error("[Delegation] applyDelegationsOnAssign hook error:", err)); } return row; } async removeTaskAssignee(taskId: number, userId: number, organizationId: number): Promise { await db.delete(taskAssignees).where(and( eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, userId), eq(taskAssignees.organizationId, organizationId), )); invalidateAccessibleTasksForUser(userId, organizationId); } // ===================== // Task User Access // ===================== async getTaskUserAccess(taskId: number, organizationId: number): Promise> { 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 { 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(); // Первая фиксация доступа к задаче — сбрасываем кэш пользователя invalidateAccessibleTasksForUser(userId, organizationId); } async upsertTaskUserAccess(taskId: number, userId: number, organizationId: number, grantedVia: string): Promise { 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))); invalidateAccessibleTasksForUser(userId, organizationId); } else { await db.insert(taskUserAccess).values({ taskId, userId, organizationId, grantedVia, roleId: null, }).onConflictDoNothing(); invalidateAccessibleTasksForUser(userId, organizationId); } } async revokeTaskUserAccess(taskId: number, userId: number, organizationId: number): Promise { 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), )); invalidateAccessibleTasksForUser(userId, organizationId); } 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 { 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 { 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 }, }); invalidateAccessibleTasksForUser(userId, organizationId); } } /** * 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 | null> { // TTL-кэш: горячий путь списков задач. null (view_all) тоже кэшируется — // TtlCache.get возвращает undefined только при промахе. const cacheKey = accessibleTasksKey(userId, organizationId); const cached = accessibleTasksCache.get | null>(cacheKey); if (cached !== undefined) return cached; const hasViewAll = await this.hasAppRolePermission(appRole, ['tasks.view_all']); if (hasViewAll) { accessibleTasksCache.set(cacheKey, null); 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(); for (const row of result.rows as { id: number }[]) { ids.add(Number(row.id)); } accessibleTasksCache.set(cacheKey, ids); return ids; } // LLM Providers async getLlmProviders(organizationId: number): Promise { return db.select().from(llmProviders).where(eq(llmProviders.organizationId, organizationId)).orderBy(asc(llmProviders.createdAt)); } async getLlmProvider(id: number, organizationId: number): Promise { 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 { const [row] = await db.insert(llmProviders).values(data).returning(); return row; } async updateLlmProvider(id: number, organizationId: number, updates: Partial>): Promise { 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 { await db.delete(llmProviders).where(and(eq(llmProviders.id, id), eq(llmProviders.organizationId, organizationId))); } // RAG Settings async getRagSettings(organizationId: number): Promise { const [row] = await db.select().from(ragSettings).where(eq(ragSettings.organizationId, organizationId)); return row || undefined; } async upsertRagSettings(organizationId: number, updates: Partial>): Promise { 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 { 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(); invalidateAccessibleTasksForOrg(data.organizationId); 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(); invalidateAccessibleTasksForOrg(organizationId); return row; } async deleteFormAccessRule(id: number, organizationId: number) { await db.delete(formAccessRules) .where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId))); invalidateAccessibleTasksForOrg(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))); // Автор формы всегда имеет доступ — смена автора меняет доступ обоих пользователей invalidateAccessibleTasksForOrg(organizationId); } async canUserAccessForm( userId: number, formId: number, organizationId: number, requiredLevel: 'participate' | 'view_all' | 'admin' = 'participate' ): Promise { 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 = { 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; } // ===================== // Embedding Queue // ===================== async enqueueEmbedding( organizationId: number, entityType: 'form' | 'task' | 'task_message', entityId: number, operation: 'upsert' | 'delete' = 'upsert' ): Promise { await db .insert(embeddingQueue) .values({ organizationId, entityType, entityId, operation, createdAt: new Date(), }) .onConflictDoNothing({ target: [embeddingQueue.organizationId, embeddingQueue.entityType, embeddingQueue.entityId], // Уникальный индекс embedding_queue_pending_entity_idx частичный // (WHERE processed_at IS NULL) — без этого предиката Postgres не находит // подходящий constraint и весь INSERT падает с ошибкой. where: isNull(embeddingQueue.processedAt), }); } async getPendingEmbeddingQueue( limit: number = 1000 ): Promise> { const rows = await db .select({ id: embeddingQueue.id, organizationId: embeddingQueue.organizationId, entityType: embeddingQueue.entityType, entityId: embeddingQueue.entityId, operation: embeddingQueue.operation, }) .from(embeddingQueue) .where(isNull(embeddingQueue.processedAt)) .orderBy(asc(embeddingQueue.createdAt)) .limit(limit); return rows as Array<{ id: number; organizationId: number; entityType: 'form' | 'task' | 'task_message'; entityId: number; operation: 'upsert' | 'delete' }>; } async markEmbeddingQueueProcessed(ids: number[]): Promise { if (ids.length === 0) return; await db .update(embeddingQueue) .set({ processedAt: new Date() }) .where(inArray(embeddingQueue.id, ids)); } async clearEmbeddingQueue(): Promise { await db.delete(embeddingQueue); } }