Selection of multiple officials and role sub-categories

This commit is contained in:
2026-10-03 13:16:13 +03:30
parent 9f97371391
commit 3b128bd980
14 changed files with 904 additions and 95 deletions
+11
View File
@@ -7,6 +7,7 @@ import {
IsString,
MaxLength,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { ROLE_PERMISSION_ITEMS, type RolePermissionKey } from '@mento/shared';
@@ -34,6 +35,11 @@ export class CreateOrgRoleDto {
@ArrayUnique()
@IsIn(PERMISSION_KEYS, { each: true })
permissions?: RolePermissionKey[];
@IsOptional()
@ValidateIf((_, v) => v !== null)
@IsString()
parentId?: string | null;
}
export class UpdateOrgRoleDto {
@@ -57,6 +63,11 @@ export class UpdateOrgRoleDto {
@ArrayUnique()
@IsIn(PERMISSION_KEYS, { each: true })
permissions?: RolePermissionKey[];
@IsOptional()
@ValidateIf((_, v) => v !== null)
@IsString()
parentId?: string | null;
}
export class RolePermissionPatchItemDto {
+175 -9
View File
@@ -16,6 +16,9 @@ import {
systemKeyToUserRole,
} from './role-defaults';
/** حداکثر عمق درخت نقش (۰ = ریشه) */
const MAX_ROLE_DEPTH = 4;
type RoleRow = {
id: string;
name: string;
@@ -23,6 +26,8 @@ type RoleRow = {
color: string;
permissions: string[];
systemKey: string | null;
parentId: string | null;
createdAt: Date;
memberships: Array<{
user: {
id: string;
@@ -30,6 +35,7 @@ type RoleRow = {
avatarStorageKey: string | null;
};
}>;
_count?: { children: number };
};
const ROLE_MEMBERS_INCLUDE = {
@@ -39,6 +45,7 @@ const ROLE_MEMBERS_INCLUDE = {
},
orderBy: { createdAt: 'asc' as const },
},
_count: { select: { children: true } },
};
@Injectable()
@@ -52,7 +59,7 @@ export class OrgRolesService {
include: ROLE_MEMBERS_INCLUDE,
orderBy: [{ systemKey: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
return this.toOrderedDtos(rows);
}
async create(
@@ -63,6 +70,7 @@ export class OrgRolesService {
description?: string;
color?: string;
permissions?: RolePermissionKey[];
parentId?: string | null;
},
): Promise<OrgRoleDto> {
this.assertManager(actorRole);
@@ -70,18 +78,33 @@ export class OrgRolesService {
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 ?? 'teal',
permissions: dto.permissions?.length ? dto.permissions : [...ALL_ROLE_PERMISSIONS],
color: dto.color ?? parent?.color ?? 'teal',
permissions,
parentId,
},
include: ROLE_MEMBERS_INCLUDE,
});
return this.toDto(row);
return (await this.list(organizationId)).find((r) => r.id === row.id)!;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('نقشی با این نام از قبل وجود دارد');
@@ -99,6 +122,7 @@ export class OrgRolesService {
description?: string;
color?: string;
permissions?: RolePermissionKey[];
parentId?: string | null;
},
): Promise<OrgRoleDto> {
this.assertManager(actorRole);
@@ -117,15 +141,38 @@ export class OrgRolesService {
}
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.permissions !== undefined) {
data.permissions = sanitizePermissions(dto.permissions);
}
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 {
const row = await this.prisma.orgRole.update({
await this.prisma.orgRole.update({
where: { id: existing.id },
data,
include: ROLE_MEMBERS_INCLUDE,
});
return this.toDto(row);
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('نقشی با این نام از قبل وجود دارد');
@@ -156,11 +203,17 @@ export class OrgRolesService {
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 };
}
@@ -180,6 +233,7 @@ export class OrgRolesService {
color: seed.color,
permissions: seed.permissions,
systemKey: seed.systemKey,
parentId: null,
},
});
byKey.set(seed.systemKey, created);
@@ -234,7 +288,115 @@ export class OrgRolesService {
}
}
private toDto(row: RoleRow): OrgRoleDto {
/**
* 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<string>();
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<string>();
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<string | null, RoleRow[]>();
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<string, string>,
): OrgRoleDto {
const systemKey =
row.systemKey === 'owner' ||
row.systemKey === 'admin' ||
@@ -249,6 +411,10 @@ export class OrgRolesService {
color: row.color,
permissions: sanitizePermissions(row.permissions),
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,