import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import type { DataAccessPolicy, OrgRoleDto, RolePermissionKey, UserRole, } from '@mento/shared'; import { parseDataAccessPolicy } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { ALL_ROLE_PERMISSIONS, defaultColorForSystemKey, sanitizePermissions, SYSTEM_ROLE_SEEDS, systemKeyToUserRole, } from './role-defaults'; /** حداکثر عمق درخت نقش (۰ = ریشه) */ const MAX_ROLE_DEPTH = 4; type RoleRow = { id: string; name: string; description: string; color: string; permissions: string[]; dataAccessPolicy?: unknown; treasuryAccountIds?: string[]; treasuryOwnAccounts?: boolean; systemKey: string | null; parentId: string | null; createdAt: Date; memberships: Array<{ user: { id: string; fullName: string; avatarStorageKey: string | null; }; }>; _count?: { children: number }; }; const ROLE_MEMBERS_INCLUDE = { memberships: { include: { user: { select: { id: true, fullName: true, avatarStorageKey: true } }, }, orderBy: { createdAt: 'asc' as const }, }, _count: { select: { children: true } }, }; @Injectable() export class OrgRolesService { constructor(private readonly prisma: PrismaService) {} async list(organizationId: string): Promise { await this.ensureDefaultRoles(organizationId); const rows = await this.prisma.orgRole.findMany({ where: { organizationId }, include: ROLE_MEMBERS_INCLUDE, orderBy: [{ systemKey: 'asc' }, { createdAt: 'asc' }], }); return this.toOrderedDtos(rows); } async create( organizationId: string, actorRole: UserRole, dto: { name: string; description?: string; color?: string; permissions?: RolePermissionKey[]; dataAccessPolicy?: DataAccessPolicy; parentId?: string | null; treasuryAccountIds?: string[]; treasuryOwnAccounts?: boolean; }, ): Promise { this.assertManager(actorRole); const name = dto.name.trim(); if (name.length < 2) { throw new BadRequestException('نام نقش باید حداقل دو حرف باشد'); } const parentId = dto.parentId?.trim() || null; let parent: { id: string; color: string; permissions: string[] } | null = null; if (parentId) { parent = await this.assertValidParent(organizationId, parentId, null); } const permissions = dto.permissions?.length ? sanitizePermissions(dto.permissions) : parent ? sanitizePermissions(parent.permissions) : [...ALL_ROLE_PERMISSIONS]; try { const row = await this.prisma.orgRole.create({ data: { organizationId, name, description: (dto.description ?? '').trim(), color: dto.color ?? parent?.color ?? 'teal', permissions, treasuryAccountIds: dto.treasuryAccountIds ?? [], treasuryOwnAccounts: dto.treasuryOwnAccounts ?? false, parentId, ...(dto.dataAccessPolicy ? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) } : {}), }, include: ROLE_MEMBERS_INCLUDE, }); return (await this.list(organizationId)).find((r) => r.id === row.id)!; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('نقشی با این نام از قبل وجود دارد'); } throw e; } } async update( organizationId: string, actorRole: UserRole, id: string, dto: { name?: string; description?: string; color?: string; permissions?: RolePermissionKey[]; dataAccessPolicy?: DataAccessPolicy; parentId?: string | null; treasuryAccountIds?: string[]; treasuryOwnAccounts?: boolean; }, ): Promise { this.assertManager(actorRole); const existing = await this.prisma.orgRole.findFirst({ where: { id, organizationId }, }); if (!existing) throw new NotFoundException('نقش پیدا نشد'); const data: Prisma.OrgRoleUpdateInput = {}; if (dto.name !== undefined) { const name = dto.name.trim(); if (name.length < 2) { throw new BadRequestException('نام نقش باید حداقل دو حرف باشد'); } data.name = name; } if (dto.description !== undefined) data.description = dto.description.trim(); if (dto.color !== undefined) data.color = dto.color; if (dto.permissions !== undefined) { data.permissions = sanitizePermissions(dto.permissions); } if (dto.dataAccessPolicy !== undefined) { data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy); } if (dto.treasuryAccountIds !== undefined) { data.treasuryAccountIds = [...new Set(dto.treasuryAccountIds.map((id) => id.trim()).filter(Boolean))]; } if (dto.treasuryOwnAccounts !== undefined) { data.treasuryOwnAccounts = dto.treasuryOwnAccounts; } if (dto.parentId !== undefined) { const nextParentId = dto.parentId === null || dto.parentId === '' ? null : String(dto.parentId).trim(); if (existing.systemKey && nextParentId) { throw new BadRequestException('نقش سیستمی نمی‌تواند زیرمجموعهٔ نقش دیگری باشد'); } if (nextParentId === existing.id) { throw new BadRequestException('نقش نمی‌تواند والد خودش باشد'); } if (nextParentId) { await this.assertValidParent(organizationId, nextParentId, existing.id); data.parent = { connect: { id: nextParentId } }; } else if (!existing.systemKey) { data.parent = { disconnect: true }; } } try { await this.prisma.orgRole.update({ where: { id: existing.id }, data, }); const listed = await this.list(organizationId); const found = listed.find((r) => r.id === id); if (!found) throw new NotFoundException('نقش پیدا نشد'); return found; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('نقشی با این نام از قبل وجود دارد'); } throw e; } } async bulkUpdatePermissions( organizationId: string, actorRole: UserRole, items: Array<{ id: string; permissions: RolePermissionKey[] }>, ): Promise { this.assertManager(actorRole); await this.ensureDefaultRoles(organizationId); await this.prisma.$transaction( items.map((item) => this.prisma.orgRole.updateMany({ where: { id: item.id, organizationId }, data: { permissions: sanitizePermissions(item.permissions) }, }), ), ); return this.list(organizationId); } async remove(organizationId: string, actorRole: UserRole, id: string): Promise<{ ok: true }> { this.assertManager(actorRole); const existing = await this.prisma.orgRole.findFirst({ where: { id, organizationId }, include: { _count: { select: { children: true } } }, }); if (!existing) throw new NotFoundException('نقش پیدا نشد'); if (existing.systemKey) { throw new BadRequestException('نقش‌های سیستمی قابل حذف نیستند'); } if (existing._count.children > 0) { throw new BadRequestException( 'ابتدا زیرنقش‌های این نقش را منتقل یا حذف کنید', ); } await this.prisma.orgRole.delete({ where: { id: existing.id } }); return { ok: true }; } async ensureDefaultRoles(organizationId: string) { const existing = await this.prisma.orgRole.findMany({ where: { organizationId }, }); const byKey = new Map(existing.filter((r) => r.systemKey).map((r) => [r.systemKey!, r])); for (const seed of SYSTEM_ROLE_SEEDS) { if (byKey.has(seed.systemKey)) continue; const created = await this.prisma.orgRole.create({ data: { organizationId, name: seed.name, description: seed.description, color: seed.color, permissions: seed.permissions, systemKey: seed.systemKey, parentId: null, }, }); byKey.set(seed.systemKey, created); } const unassigned = await this.prisma.membership.findMany({ where: { organizationId, orgRoleId: null }, select: { id: true, role: true }, }); for (const membership of unassigned) { const role = byKey.get(membership.role); if (!role) continue; await this.prisma.membership.update({ where: { id: membership.id }, data: { orgRoleId: role.id }, }); } } async resolveInviteRole( organizationId: string, dto: { orgRoleId?: string; role?: Exclude }, ): Promise<{ orgRoleId: string; role: UserRole }> { await this.ensureDefaultRoles(organizationId); if (dto.orgRoleId) { const orgRole = await this.prisma.orgRole.findFirst({ where: { id: dto.orgRoleId, organizationId }, }); if (!orgRole) throw new BadRequestException('نقش انتخاب‌شده معتبر نیست'); return { orgRoleId: orgRole.id, role: systemKeyToUserRole(orgRole.systemKey), }; } const role = dto.role ?? 'sales'; const orgRole = await this.prisma.orgRole.findFirst({ where: { organizationId, systemKey: role }, }); if (!orgRole) { throw new BadRequestException('نقش پیش‌فرض پیدا نشد'); } return { orgRoleId: orgRole.id, role }; } defaultColorFor(role: UserRole) { return defaultColorForSystemKey(role); } private assertManager(actorRole: UserRole) { if (actorRole !== 'owner' && actorRole !== 'admin') { throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش‌ها را مدیریت کند'); } } /** * Validates parent exists in org, depth limit, and no cycles when moving `movingId`. */ private async assertValidParent( organizationId: string, parentId: string, movingId: string | null, ): Promise<{ id: string; color: string; permissions: string[] }> { const parent = await this.prisma.orgRole.findFirst({ where: { id: parentId, organizationId }, select: { id: true, color: true, permissions: true, parentId: true }, }); if (!parent) { throw new BadRequestException('نقش والد پیدا نشد'); } const all = await this.prisma.orgRole.findMany({ where: { organizationId }, select: { id: true, parentId: true }, }); const parentById = new Map(all.map((r) => [r.id, r.parentId])); // Depth of parent + 1 for the new/moved node let depth = 0; let cursor: string | null = parent.id; const seen = new Set(); while (cursor) { if (seen.has(cursor)) { throw new BadRequestException('ساختار نقش‌ها حلقه دارد'); } seen.add(cursor); depth += 1; cursor = parentById.get(cursor) ?? null; if (depth > MAX_ROLE_DEPTH + 1) break; } if (depth > MAX_ROLE_DEPTH) { throw new BadRequestException( `حداکثر عمق زیرنقش ${MAX_ROLE_DEPTH + 1} سطح است`, ); } if (movingId) { // Cannot set parent to a descendant of movingId const descendants = new Set(); const queue = [movingId]; while (queue.length) { const current = queue.shift()!; for (const row of all) { if (row.parentId === current && !descendants.has(row.id)) { descendants.add(row.id); queue.push(row.id); } } } if (descendants.has(parentId)) { throw new BadRequestException( 'نمی‌توان نقش را زیرمجموعهٔ زیرنقش خودش قرار داد', ); } } return parent; } private toOrderedDtos(rows: RoleRow[]): OrgRoleDto[] { const byParent = new Map(); for (const row of rows) { const key = row.parentId ?? null; const list = byParent.get(key) ?? []; list.push(row); byParent.set(key, list); } for (const list of byParent.values()) { list.sort((a, b) => { const ak = a.systemKey ? `0-${a.systemKey}` : `1-${a.name}`; const bk = b.systemKey ? `0-${b.systemKey}` : `1-${b.name}`; const byKey = ak.localeCompare(bk, 'fa'); if (byKey !== 0) return byKey; return a.createdAt.getTime() - b.createdAt.getTime(); }); } const nameById = new Map(rows.map((r) => [r.id, r.name])); const out: OrgRoleDto[] = []; const walk = (parentId: string | null, depth: number) => { const children = byParent.get(parentId) ?? []; for (const row of children) { out.push(this.toDto(row, depth, nameById)); walk(row.id, depth + 1); } }; walk(null, 0); // Orphans (broken parent pointer) — append at end const included = new Set(out.map((r) => r.id)); for (const row of rows) { if (!included.has(row.id)) { out.push(this.toDto(row, 0, nameById)); } } return out; } private toDto( row: RoleRow, depth: number, nameById: Map, ): OrgRoleDto { const systemKey = row.systemKey === 'owner' || row.systemKey === 'admin' || row.systemKey === 'sales' || row.systemKey === 'support' ? row.systemKey : null; return { id: row.id, name: row.name, description: row.description, color: row.color, permissions: sanitizePermissions(row.permissions), dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy), treasuryAccountIds: row.treasuryAccountIds ?? [], treasuryOwnAccounts: row.treasuryOwnAccounts ?? false, systemKey, parentId: row.parentId, parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null, depth, childCount: row._count?.children ?? 0, userCount: row.memberships.length, members: row.memberships.map((membership) => ({ id: membership.user.id, fullName: membership.user.fullName, hasAvatar: Boolean(membership.user.avatarStorageKey), })), }; } }