359 lines
18 KiB
TypeScript
359 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";
|
|
|
|
// 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();
|
|
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)));
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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)));
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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)));
|
|
}
|
|
}
|