Шаг 0.12 плана production-готовности: - accessibleTasksCache (45с) per userId:orgId внутри storage-метода - инвалидация в storage-слое: роли, делегирования, form access, assignees, createTask/updateTask, appRole пользователя - 9 новых юнит-тестов (65/65 зелёные)
373 lines
18 KiB
TypeScript
373 lines
18 KiB
TypeScript
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)));
|
||
}
|
||
}
|