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:
2026-09-07 22:27:33 +03:00
parent 82d577c797
commit 23c49adcd1
9 changed files with 231 additions and 2 deletions

View File

@@ -2,6 +2,7 @@ import { users, appRoles, permissions, appRolePermissions, roleMembers, roles }
import { db } from "../db";
import { eq, and, asc, inArray } from "drizzle-orm";
import { BillingStorage } from "./billing.storage";
import { invalidateAccessibleTasksForUser, invalidateAccessibleTasksForOrg } from "../utils/cache";
// In-memory permission cache
let _permCache: Map<string, string[]> | null = null;
@@ -163,6 +164,10 @@ export class RolesStorage extends BillingStorage {
if (!manager) throw new Error('Руководитель не найден в этой организации');
}
const [role] = await db.update(roles).set(updates).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId))).returning();
// Смена parentId меняет наследование доступа по иерархии ролей — сброс по организации
if ('parentId' in updates) {
invalidateAccessibleTasksForOrg(organizationId);
}
return role;
}
@@ -170,6 +175,7 @@ export class RolesStorage extends BillingStorage {
const { roles } = await import('@shared/schema');
await db.update(roles).set({ parentId: null }).where(and(eq(roles.parentId, id), eq(roles.organizationId, organizationId)));
await db.delete(roles).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId)));
invalidateAccessibleTasksForOrg(organizationId);
}
async getRoleMembersByRole(roleId: number): Promise<{ id: number; userId: number; createdAt: Date | null }[]> {
@@ -217,6 +223,7 @@ export class RolesStorage extends BillingStorage {
const [targetUser] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.organizationId, organizationId)));
if (!targetUser) throw new Error('Пользователь не найден в этой организации');
await db.insert(roleMembers).values({ roleId, userId }).onConflictDoNothing();
invalidateAccessibleTasksForUser(userId, organizationId);
}
async removeRoleMember(roleId: number, userId: number, organizationId: number) {
@@ -226,6 +233,7 @@ export class RolesStorage extends BillingStorage {
const [targetUser] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.organizationId, organizationId)));
if (!targetUser) throw new Error('Пользователь не найден в этой организации');
await db.delete(roleMembers).where(and(eq(roleMembers.roleId, roleId), eq(roleMembers.userId, userId)));
invalidateAccessibleTasksForUser(userId, organizationId);
}
async moveRoleMember(sourceRoleId: number, targetRoleId: number | null, userId: number, organizationId: number) {
@@ -247,6 +255,7 @@ export class RolesStorage extends BillingStorage {
if (targetRoleId !== null) {
await db.insert(roleMembers).values({ roleId: targetRoleId, userId }).onConflictDoNothing();
}
invalidateAccessibleTasksForUser(userId, organizationId);
}
async getUserRoles(userId: number, organizationId: number) {
@@ -343,6 +352,11 @@ export class RolesStorage extends BillingStorage {
const values = roleIds.map(roleId => ({ userId, roleId }));
await db.insert(roleMembers).values(values).onConflictDoNothing();
}
// organizationId не приходит аргументом — сбрасываем кэш по всем организациям пользователя
const userRows = await db.select({ organizationId: users.organizationId }).from(users).where(eq(users.id, userId)).limit(1);
if (userRows[0]?.organizationId) {
invalidateAccessibleTasksForUser(userId, userRows[0].organizationId);
}
}
async getUserOrganizationalRoles(userId: number, organizationId: number) {