Files
iistwin/server/storage/system.storage.ts

496 lines
20 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, 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 { 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;
}
// =====================
// Embedding Queue
// =====================
async enqueueEmbedding(
organizationId: number,
entityType: 'form' | 'task' | 'task_message',
entityId: number,
operation: 'upsert' | 'delete' = 'upsert'
): Promise<void> {
await db
.insert(embeddingQueue)
.values({
organizationId,
entityType,
entityId,
operation,
createdAt: new Date(),
})
.onConflictDoNothing({ target: [embeddingQueue.organizationId, embeddingQueue.entityType, embeddingQueue.entityId] });
}
async getPendingEmbeddingQueue(
limit: number = 1000
): Promise<Array<{ id: number; organizationId: number; entityType: 'form' | 'task' | 'task_message'; entityId: number; operation: 'upsert' | 'delete' }>> {
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<void> {
if (ids.length === 0) return;
await db
.update(embeddingQueue)
.set({ processedAt: new Date() })
.where(inArray(embeddingQueue.id, ids));
}
async clearEmbeddingQueue(): Promise<void> {
await db.delete(embeddingQueue);
}
}