Files
iistwin/server/storage/roles.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

373 lines
18 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, appRoles, permissions, appRolePermissions, roleMembers, roles } from "@shared/schema";
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;
let _permCacheTs = 0;
const PERM_CACHE_TTL = 60_000;
async function getPermissionCache(): Promise<Map<string, string[]>> {
const now = Date.now();
if (_permCache && (now - _permCacheTs) < PERM_CACHE_TTL) {
return _permCache;
}
const rows = await db.select({
appRoleSlug: appRoles.slug,
permissionCode: permissions.code,
}).from(appRolePermissions)
.innerJoin(appRoles, eq(appRolePermissions.appRoleId, appRoles.id))
.innerJoin(permissions, eq(appRolePermissions.permissionId, permissions.id));
const map = new Map<string, string[]>();
for (const r of rows) {
if (!map.has(r.appRoleSlug)) map.set(r.appRoleSlug, []);
map.get(r.appRoleSlug)!.push(r.permissionCode);
}
_permCache = map;
_permCacheTs = now;
return map;
}
export function invalidatePermissionCache(): void {
_permCache = null;
_permCacheTs = 0;
}
export class RolesStorage extends BillingStorage {
// =====================
// Roles
// =====================
async getRoles(organizationId: number) {
const { roles, roleMembers } = await import('@shared/schema');
const roleRows = await db.select({
id: roles.id,
organizationId: roles.organizationId,
name: roles.name,
description: roles.description,
parentId: roles.parentId,
managerId: roles.managerId,
gpsAccess: roles.gpsAccess,
financeAccess: roles.financeAccess,
createdAt: roles.createdAt,
}).from(roles).where(eq(roles.organizationId, organizationId)).orderBy(asc(roles.name));
if (roleRows.length === 0) return [];
const roleIds = roleRows.map(r => r.id);
const memberRows = await db.select({
roleId: roleMembers.roleId,
memberId: roleMembers.id,
userId: roleMembers.userId,
user: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
email: users.email,
},
}).from(roleMembers)
.innerJoin(users, eq(roleMembers.userId, users.id))
.where(inArray(roleMembers.roleId, roleIds));
const membersByRole = new Map<number, typeof memberRows>();
memberRows.forEach(m => {
if (!membersByRole.has(m.roleId)) membersByRole.set(m.roleId, []);
membersByRole.get(m.roleId)!.push(m);
});
const managerIds = roleRows.map(r => r.managerId).filter((id): id is number => id !== null && id !== undefined);
const managers = managerIds.length > 0
? await db.select({
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
middleName: users.middleName,
email: users.email,
}).from(users).where(inArray(users.id, managerIds))
: [];
const managerMap = new Map(managers.map(m => [m.id, m]));
// Build role tree for recursive member counting
const roleMap = new Map(roleRows.map(r => [r.id, { ...r, children: [] as typeof roleRows, directMemberCount: (membersByRole.get(r.id) ?? []).length }]));
const treeRoots: typeof roleMap extends Map<number, infer V> ? V[] : never[] = [];
for (const role of roleMap.values()) {
if (role.parentId && roleMap.has(role.parentId)) {
(roleMap.get(role.parentId) as any).children.push(role);
} else {
treeRoots.push(role as any);
}
}
const computeTotal = (node: any): number => {
let total = node.directMemberCount;
for (const child of node.children) {
total += computeTotal(child);
}
return total;
};
for (const role of roleMap.values()) {
(role as any).totalMemberCount = computeTotal(role);
}
return roleRows.map(role => ({
...role,
memberCount: (roleMap.get(role.id) as any).totalMemberCount,
directMemberCount: (roleMap.get(role.id) as any).directMemberCount,
totalMemberCount: (roleMap.get(role.id) as any).totalMemberCount,
members: (membersByRole.get(role.id) ?? []).map(m => ({
id: m.memberId,
userId: m.userId,
user: m.user,
})),
manager: role.managerId ? managerMap.get(role.managerId) ?? null : null,
}));
}
async getRole(id: number, organizationId: number) {
const { roles } = await import('@shared/schema');
const [role] = await db.select().from(roles).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId)));
return role;
}
async createRole(data: { organizationId: number; name: string; description?: string | null; parentId?: number | null; managerId?: number | null }) {
const { roles } = await import('@shared/schema');
if (data.parentId) {
const [parent] = await db.select().from(roles).where(and(eq(roles.id, data.parentId), eq(roles.organizationId, data.organizationId)));
if (!parent) throw new Error('Родительская роль не найдена в этой организации');
}
if (data.managerId) {
const [manager] = await db.select().from(users).where(and(eq(users.id, data.managerId), eq(users.organizationId, data.organizationId)));
if (!manager) throw new Error('Руководитель не найден в этой организации');
}
const [role] = await db.insert(roles).values({
organizationId: data.organizationId,
name: data.name,
description: data.description ?? null,
parentId: data.parentId ?? null,
managerId: data.managerId ?? null,
}).returning();
return role;
}
async updateRole(id: number, organizationId: number, updates: { name?: string; description?: string | null; parentId?: number | null; managerId?: number | null; gpsAccess?: boolean; financeAccess?: boolean }) {
const { roles } = await import('@shared/schema');
if (updates.parentId) {
if (updates.parentId === id) throw new Error('Роль не может быть родителем самой себя');
const [parent] = await db.select().from(roles).where(and(eq(roles.id, updates.parentId), eq(roles.organizationId, organizationId)));
if (!parent) throw new Error('Родительская роль не найдена в этой организации');
}
if (updates.managerId) {
const [manager] = await db.select().from(users).where(and(eq(users.id, updates.managerId), eq(users.organizationId, organizationId)));
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;
}
async deleteRole(id: number, organizationId: number) {
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 }[]> {
const { roleMembers } = await import('@shared/schema');
return db.select().from(roleMembers).where(eq(roleMembers.roleId, roleId));
}
async getRoleMembers(roleId: number, organizationId: number) {
const { roles, roleMembers } = await import('@shared/schema');
const [role] = await db.select().from(roles).where(and(eq(roles.id, roleId), eq(roles.organizationId, organizationId)));
if (!role) throw new Error('Роль не найдена');
const members = await db.select({
id: roleMembers.id,
userId: roleMembers.userId,
createdAt: roleMembers.createdAt,
user: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
email: users.email,
},
}).from(roleMembers)
.innerJoin(users, eq(roleMembers.userId, users.id))
.where(eq(roleMembers.roleId, roleId));
let manager = null;
if (role.managerId) {
const [managerRow] = await db.select({
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
middleName: users.middleName,
email: users.email,
}).from(users).where(eq(users.id, role.managerId));
manager = managerRow ?? null;
}
return { ...role, members, manager };
}
async addRoleMember(roleId: number, userId: number, organizationId: number) {
const { roles, roleMembers } = await import('@shared/schema');
const [role] = await db.select().from(roles).where(and(eq(roles.id, roleId), eq(roles.organizationId, organizationId)));
if (!role) throw new Error('Роль не найдена');
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) {
const { roles, roleMembers } = await import('@shared/schema');
const [role] = await db.select().from(roles).where(and(eq(roles.id, roleId), eq(roles.organizationId, organizationId)));
if (!role) throw new Error('Роль не найдена');
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) {
const { roles, roleMembers } = await import('@shared/schema');
const [sourceRole] = await db.select().from(roles).where(and(eq(roles.id, sourceRoleId), eq(roles.organizationId, organizationId)));
if (!sourceRole) throw new Error('Исходная роль не найдена');
if (targetRoleId !== null) {
const [targetRole] = await db.select().from(roles).where(and(eq(roles.id, targetRoleId), eq(roles.organizationId, organizationId)));
if (!targetRole) throw new Error('Целевая роль не найдена');
}
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, sourceRoleId), eq(roleMembers.userId, userId)));
if (targetRoleId !== null) {
await db.insert(roleMembers).values({ roleId: targetRoleId, userId }).onConflictDoNothing();
}
invalidateAccessibleTasksForUser(userId, organizationId);
}
async getUserRoles(userId: number, organizationId: number) {
const { roles, roleMembers } = await import('@shared/schema');
return db.select({
id: roles.id,
organizationId: roles.organizationId,
name: roles.name,
description: roles.description,
parentId: roles.parentId,
managerId: roles.managerId,
gpsAccess: roles.gpsAccess,
financeAccess: roles.financeAccess,
createdAt: roles.createdAt,
}).from(roleMembers)
.innerJoin(roles, eq(roleMembers.roleId, roles.id))
.where(and(eq(roleMembers.userId, userId), eq(roles.organizationId, organizationId)));
}
// =====================
// App Roles & Permissions
// =====================
async hasAppRolePermission(appRoleSlug: string, permissionCodes: string[]): Promise<boolean> {
const cache = await getPermissionCache();
const perms = cache.get(appRoleSlug) || [];
return permissionCodes.some(c => perms.includes(c));
}
async getAppRolePermissions(appRoleSlug: string): Promise<string[]> {
const cache = await getPermissionCache();
return cache.get(appRoleSlug) || [];
}
async seedAppRolesAndPermissions(): Promise<void> {
// Seed app roles
await db.insert(appRoles).values([
{ slug: 'admin', name: 'Администратор', description: 'Полный доступ ко всем функциям приложения', isSystem: true },
{ slug: 'user', name: 'Пользователь', description: 'Базовый доступ к задачам и чату', isSystem: true },
]).onConflictDoNothing();
// Seed permissions
const perms = [
{ code: 'super_admin.access', name: 'Доступ к супер-админке', description: 'Управление организациями на уровне супер-админа', module: 'system' },
{ code: 'users.manage', name: 'Управление пользователями', description: 'Создание, редактирование и удаление пользователей', module: 'users' },
{ code: 'roles.manage', name: 'Управление ролями', description: 'Создание и редактирование организационных ролей', module: 'users' },
{ code: 'forms.manage', name: 'Управление формами', description: 'Создание и редактирование форм и полей', module: 'forms' },
{ code: 'tasks.view_all', name: 'Видеть все задачи', description: 'Доступ ко всем задачам организации', module: 'tasks' },
{ code: 'tasks.edit_all', name: 'Редактировать все задачи', description: 'Редактирование любых задач', module: 'tasks' },
{ code: 'directories.manage', name: 'Управление справочниками', description: 'Создание и редактирование справочников', module: 'directories' },
{ code: 'finance.view', name: 'Просмотр финансов', description: 'Доступ к модулю Финансы', module: 'finance' },
{ code: 'finance.manage', name: 'Управление финансами', description: 'Редактирование финансовых данных', module: 'finance' },
{ code: 'billing.view', name: 'Просмотр биллинга', description: 'Просмотр биллинговой информации', module: 'billing' },
{ code: 'billing.manage', name: 'Управление биллингом', description: 'Управление биллингом и оплатой', module: 'billing' },
{ code: 'settings.manage', name: 'Управление настройками', description: 'Настройки организации', module: 'settings' },
{ code: 'automations.manage', name: 'Управление автоматизациями', description: 'Создание и редактирование автоматизаций', module: 'automations' },
{ code: 'bots.manage', name: 'Управление ботами', description: 'Создание и настройка ботов', module: 'bots' },
{ code: 'chat.manage', name: 'Управление чатами', description: 'Администрирование групповых чатов', module: 'chat' },
{ code: 'reports.view', name: 'Просмотр отчётов', description: 'Доступ к отчётам', module: 'reports' },
{ code: 'profile.edit', name: 'Редактирование профиля', description: 'Изменение собственного профиля', module: 'profile' },
{ code: 'inbox.view', name: 'Доступ к inbox', description: 'Просмотр inbox и главной страницы', module: 'inbox' },
];
await db.insert(permissions).values(perms).onConflictDoNothing();
// Link admin -> all permissions
const adminRole = await db.select().from(appRoles).where(eq(appRoles.slug, 'admin')).limit(1);
if (adminRole.length > 0) {
const allPerms = await db.select({ id: permissions.id }).from(permissions);
for (const p of allPerms) {
await db.insert(appRolePermissions).values({ appRoleId: adminRole[0].id, permissionId: p.id }).onConflictDoNothing();
}
}
// Link user -> minimal permissions
const userRole = await db.select().from(appRoles).where(eq(appRoles.slug, 'user')).limit(1);
if (userRole.length > 0) {
const minPermCodes = ['profile.edit', 'inbox.view'];
const minPerms = await db.select({ id: permissions.id }).from(permissions).where(inArray(permissions.code, minPermCodes));
for (const p of minPerms) {
await db.insert(appRolePermissions).values({ appRoleId: userRole[0].id, permissionId: p.id }).onConflictDoNothing();
}
}
invalidatePermissionCache();
}
// =====================
// User Organizational Roles
// =====================
async setUserOrganizationalRoles(userId: number, roleIds: number[]): Promise<void> {
await db.delete(roleMembers).where(eq(roleMembers.userId, userId));
if (roleIds.length > 0) {
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) {
return db.select({
id: roles.id,
name: roles.name,
description: roles.description,
parentId: roles.parentId,
}).from(roleMembers)
.innerJoin(roles, eq(roleMembers.roleId, roles.id))
.where(and(eq(roleMembers.userId, userId), eq(roles.organizationId, organizationId)));
}
}