Files
iistwin/server/storage/system.storage.ts
Ильяс Султанов 23c49adcd1 perf(access): TTL-кэш getAccessibleTaskIds с точечной инвалидацией
Шаг 0.12 плана production-готовности:
- accessibleTasksCache (45с) per userId:orgId внутри storage-метода
- инвалидация в storage-слое: роли, делегирования, form access,
  assignees, createTask/updateTask, appRole пользователя
- 9 новых юнит-тестов (65/65 зелёные)
2026-09-07 22:27:33 +03:00

564 lines
23 KiB
TypeScript
Raw Permalink 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 {
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<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();
// Роль задачи меняет доступ всех членов роли (и ветку «нет ролей — доступ всем»)
invalidateAccessibleTasksForOrg(organizationId);
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),
));
invalidateAccessibleTasksForOrg(organizationId);
}
async addTaskAssignee(
taskId: number,
userId: number,
organizationId: number,
options?: { delegatedFromUserId?: number | null; pendingReview?: boolean }
): 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) {
// Явный запрос «на ознакомление» — проставляем флаг на существующей строке
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<void> {
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<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();
// Первая фиксация доступа к задаче — сбрасываем кэш пользователя
invalidateAccessibleTasksForUser(userId, organizationId);
}
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)));
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<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),
));
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<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 },
});
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<Set<number> | null> {
// TTL-кэш: горячий путь списков задач. null (view_all) тоже кэшируется —
// TtlCache.get возвращает undefined только при промахе.
const cacheKey = accessibleTasksKey(userId, organizationId);
const cached = accessibleTasksCache.get<Set<number> | 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<number>();
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<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();
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<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],
// Уникальный индекс embedding_queue_pending_entity_idx частичный
// (WHERE processed_at IS NULL) — без этого предиката Postgres не находит
// подходящий constraint и весь INSERT падает с ошибкой.
where: isNull(embeddingQueue.processedAt),
});
}
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);
}
}