perf(access): TTL-кэш getAccessibleTaskIds с точечной инвалидацией
Шаг 0.12 плана production-готовности: - accessibleTasksCache (45с) per userId:orgId внутри storage-метода - инвалидация в storage-слое: роли, делегирования, form access, assignees, createTask/updateTask, appRole пользователя - 9 новых юнит-тестов (65/65 зелёные)
This commit is contained in:
@@ -5,6 +5,12 @@ import { llmProviders, type LlmProvider, type InsertLlmProvider } from "@shared/
|
||||
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 {
|
||||
// =====================
|
||||
@@ -37,6 +43,8 @@ export class SystemStorage extends RolesStorage {
|
||||
.limit(1);
|
||||
if (existing.length > 0) return existing[0];
|
||||
const [row] = await db.insert(taskRoles).values({ taskId, roleId, organizationId }).returning();
|
||||
// Роль задачи меняет доступ всех членов роли (и ветку «нет ролей — доступ всем»)
|
||||
invalidateAccessibleTasksForOrg(organizationId);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -46,6 +54,7 @@ export class SystemStorage extends RolesStorage {
|
||||
eq(taskRoles.roleId, roleId),
|
||||
eq(taskRoles.organizationId, organizationId),
|
||||
));
|
||||
invalidateAccessibleTasksForOrg(organizationId);
|
||||
}
|
||||
|
||||
async addTaskAssignee(
|
||||
@@ -75,6 +84,8 @@ export class SystemStorage extends RolesStorage {
|
||||
delegatedFromUserId: options?.delegatedFromUserId ?? null,
|
||||
pendingReview: options?.pendingReview ?? false,
|
||||
}).returning();
|
||||
// Новый исполнитель получает доступ к задаче — сбрасываем его кэш доступа
|
||||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||
// Хук замещения: прямое назначение резолвит активного заместителя.
|
||||
// Пропускаем строки заместителей/ознакомления — иначе получили бы рекурсию.
|
||||
// Динамический import разрывает цикл storage → delegation.service → storage.
|
||||
@@ -92,6 +103,7 @@ export class SystemStorage extends RolesStorage {
|
||||
eq(taskAssignees.userId, userId),
|
||||
eq(taskAssignees.organizationId, organizationId),
|
||||
));
|
||||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||
}
|
||||
|
||||
// =====================
|
||||
@@ -136,6 +148,8 @@ export class SystemStorage extends RolesStorage {
|
||||
grantedVia,
|
||||
roleId: roleId ?? null,
|
||||
}).onConflictDoNothing();
|
||||
// Первая фиксация доступа к задаче — сбрасываем кэш пользователя
|
||||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||
}
|
||||
|
||||
async upsertTaskUserAccess(taskId: number, userId: number, organizationId: number, grantedVia: string): Promise<void> {
|
||||
@@ -147,6 +161,7 @@ export class SystemStorage extends RolesStorage {
|
||||
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,
|
||||
@@ -155,6 +170,7 @@ export class SystemStorage extends RolesStorage {
|
||||
grantedVia,
|
||||
roleId: null,
|
||||
}).onConflictDoNothing();
|
||||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +183,7 @@ export class SystemStorage extends RolesStorage {
|
||||
eq(taskUserAccess.organizationId, organizationId),
|
||||
isNull(taskUserAccess.revokedAt),
|
||||
));
|
||||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||
}
|
||||
|
||||
async resolveTaskAccessGrant(taskId: number, userId: number, organizationId: number): Promise<{ grantedVia: string; roleId: number | null }> {
|
||||
@@ -220,6 +237,7 @@ export class SystemStorage extends RolesStorage {
|
||||
target: [taskUserAccess.taskId, taskUserAccess.userId],
|
||||
set: { grantedVia: 'transition', roleId: roleId ?? null, statusId },
|
||||
});
|
||||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,8 +246,17 @@ export class SystemStorage extends RolesStorage {
|
||||
* 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) return null;
|
||||
if (hasViewAll) {
|
||||
accessibleTasksCache.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
const result = await db.execute(sql`
|
||||
SELECT DISTINCT t.id
|
||||
FROM tasks t
|
||||
@@ -304,6 +331,7 @@ export class SystemStorage extends RolesStorage {
|
||||
for (const row of result.rows as { id: number }[]) {
|
||||
ids.add(Number(row.id));
|
||||
}
|
||||
accessibleTasksCache.set(cacheKey, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -413,6 +441,7 @@ export class SystemStorage extends RolesStorage {
|
||||
|
||||
async createFormAccessRule(data: InsertFormAccessRule) {
|
||||
const [row] = await db.insert(formAccessRules).values(data).returning();
|
||||
invalidateAccessibleTasksForOrg(data.organizationId);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -421,18 +450,22 @@ export class SystemStorage extends RolesStorage {
|
||||
.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(
|
||||
|
||||
Reference in New Issue
Block a user