Selection of multiple officials and role sub-categories
This commit is contained in:
@@ -109,6 +109,7 @@ export class ActivitiesExcelService {
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
assignee: { select: { fullName: true } },
|
||||
assignees: { select: { fullName: true } },
|
||||
createdBy: { select: { fullName: true } },
|
||||
},
|
||||
orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }],
|
||||
@@ -124,7 +125,9 @@ export class ActivitiesExcelService {
|
||||
title: row.title,
|
||||
body: row.description ?? '',
|
||||
result: '',
|
||||
assignee: row.assignee?.fullName ?? '',
|
||||
assignee: row.assignees?.length
|
||||
? row.assignees.map((a) => a.fullName).join('، ')
|
||||
: (row.assignee?.fullName ?? ''),
|
||||
creator: row.createdBy?.fullName ?? '',
|
||||
dueDate: formatJalaliDate(row.dueAt),
|
||||
dueTime: formatClock(row.dueAt),
|
||||
@@ -182,8 +185,17 @@ export class ActivitiesExcelService {
|
||||
const personId = await this.findPersonId(actor.organizationId, row);
|
||||
const companyId = await this.findCompanyId(actor.organizationId, row.companyName);
|
||||
const dealId = await this.findDealId(actor.organizationId, row.dealTitle);
|
||||
const assigneeId =
|
||||
(await this.findUserId(actor.organizationId, row.assignee)) ?? actor.userId;
|
||||
const assigneeNames = String(row.assignee ?? '')
|
||||
.split(/[,،]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const assigneeIds: string[] = [];
|
||||
for (const name of assigneeNames) {
|
||||
const uid = await this.findUserId(actor.organizationId, name);
|
||||
if (uid && !assigneeIds.includes(uid)) assigneeIds.push(uid);
|
||||
}
|
||||
if (!assigneeIds.length) assigneeIds.push(actor.userId);
|
||||
const assigneeId = assigneeIds[0]!;
|
||||
const creatorId =
|
||||
(await this.findUserId(actor.organizationId, row.creator)) ?? actor.userId;
|
||||
|
||||
@@ -218,6 +230,7 @@ export class ActivitiesExcelService {
|
||||
companyId,
|
||||
dealId,
|
||||
assigneeId,
|
||||
assignees: { connect: assigneeIds.map((id) => ({ id })) },
|
||||
createdById: creatorId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ type ActivityRow = {
|
||||
company: { id: string; name: string } | null;
|
||||
deal: { id: string; title: string } | null;
|
||||
assignee: { id: string; fullName: string } | null;
|
||||
assignees: Array<{ id: string; fullName: string }>;
|
||||
createdBy: { id: string; fullName: string } | null;
|
||||
};
|
||||
|
||||
@@ -49,6 +50,7 @@ const include = {
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
assignee: { select: { id: true, fullName: true } },
|
||||
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } },
|
||||
createdBy: { select: { id: true, fullName: true } },
|
||||
} satisfies Prisma.ActivityInclude;
|
||||
|
||||
@@ -186,8 +188,15 @@ export class ActivitiesService {
|
||||
): Promise<ActivityDto> {
|
||||
const linkActor = actor ?? { userId, role: 'owner' };
|
||||
await this.assertLinks(organizationId, linkActor, dto);
|
||||
const assigneeId = emptyToNull(dto.assigneeId ?? null) ?? userId;
|
||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds?.length
|
||||
? dto.assigneeIds
|
||||
: dto.assigneeId
|
||||
? [dto.assigneeId]
|
||||
: [userId],
|
||||
);
|
||||
const assigneeId = assigneeIds[0] ?? userId;
|
||||
|
||||
const row = await this.prisma.activity.create({
|
||||
data: {
|
||||
@@ -206,15 +215,18 @@ export class ActivitiesService {
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
dealId: emptyToNull(dto.dealId ?? null),
|
||||
assigneeId,
|
||||
assignees: assigneeIds.length
|
||||
? { connect: assigneeIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
createdById: userId,
|
||||
},
|
||||
include,
|
||||
});
|
||||
|
||||
if (assigneeId) {
|
||||
for (const uid of assigneeIds) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: assigneeId,
|
||||
userId: uid,
|
||||
type: 'activity_assigned',
|
||||
title: 'فعالیت جدید برای شما',
|
||||
body: row.title,
|
||||
@@ -262,10 +274,23 @@ export class ActivitiesService {
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
if ('assigneeId' in dto) {
|
||||
let assigneeConnect: Array<{ id: string }> | undefined;
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
if (!assigneeIds.length) {
|
||||
throw new BadRequestException('حداقل یک مسئول لازم است');
|
||||
}
|
||||
data.assigneeId = assigneeIds[0]!;
|
||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||
} else if ('assigneeId' in dto) {
|
||||
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||
data.assigneeId = assigneeId;
|
||||
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
||||
else assigneeConnect = [];
|
||||
}
|
||||
if (dto.status !== undefined) {
|
||||
data.status = dto.status;
|
||||
@@ -279,7 +304,12 @@ export class ActivitiesService {
|
||||
});
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data,
|
||||
data: {
|
||||
...data,
|
||||
...(assigneeConnect
|
||||
? { assignees: { set: assigneeConnect } }
|
||||
: {}),
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (dto.status === 'done' && existing?.status !== 'done') {
|
||||
@@ -340,37 +370,55 @@ export class ActivitiesService {
|
||||
},
|
||||
): Prisma.ActivityWhereInput {
|
||||
const { start, end } = dayBounds();
|
||||
const scope =
|
||||
desk === 'mine' ? { assigneeId: actor.userId } : assigneeWhere(actor);
|
||||
const facetWhere: Prisma.ActivityWhereInput = {
|
||||
...(facets?.type ? { type: facets.type } : {}),
|
||||
...(facets?.status ? { status: facets.status } : {}),
|
||||
...(facets?.assigneeId?.trim() ? { assigneeId: facets.assigneeId.trim() } : {}),
|
||||
};
|
||||
const base: Prisma.ActivityWhereInput = {
|
||||
organizationId,
|
||||
...scope,
|
||||
...facetWhere,
|
||||
...(entity?.personId ? { personId: entity.personId } : {}),
|
||||
...(entity?.companyId ? { companyId: entity.companyId } : {}),
|
||||
...(entity?.dealId ? { dealId: entity.dealId } : {}),
|
||||
...(q?.trim()
|
||||
const scope: Prisma.ActivityWhereInput =
|
||||
desk === 'mine'
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ description: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{
|
||||
person: {
|
||||
OR: [
|
||||
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ deal: { title: { contains: q.trim(), mode: 'insensitive' } } },
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
: assigneeWhere(actor);
|
||||
const facetAssignee = facets?.assigneeId?.trim();
|
||||
const andParts: Prisma.ActivityWhereInput[] = [
|
||||
scope,
|
||||
...(facets?.type ? [{ type: facets.type }] : []),
|
||||
...(facets?.status ? [{ status: facets.status }] : []),
|
||||
...(facetAssignee
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{ assigneeId: facetAssignee },
|
||||
{ assignees: { some: { id: facetAssignee } } },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(entity?.personId ? [{ personId: entity.personId }] : []),
|
||||
...(entity?.companyId ? [{ companyId: entity.companyId }] : []),
|
||||
...(entity?.dealId ? [{ dealId: entity.dealId }] : []),
|
||||
];
|
||||
if (q?.trim()) {
|
||||
const needle = q.trim();
|
||||
andParts.push({
|
||||
OR: [
|
||||
{ title: { contains: needle, mode: 'insensitive' } },
|
||||
{ description: { contains: needle, mode: 'insensitive' } },
|
||||
{
|
||||
person: {
|
||||
OR: [
|
||||
{ firstName: { contains: needle, mode: 'insensitive' } },
|
||||
{ lastName: { contains: needle, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ deal: { title: { contains: needle, mode: 'insensitive' } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
const base: Prisma.ActivityWhereInput = {
|
||||
organizationId,
|
||||
AND: andParts,
|
||||
};
|
||||
|
||||
const fromDate = range?.from ? new Date(range.from) : null;
|
||||
@@ -517,11 +565,35 @@ export class ActivitiesService {
|
||||
company: row.company,
|
||||
deal: row.deal,
|
||||
assignee: row.assignee,
|
||||
assignees: (() => {
|
||||
const list = [...(row.assignees ?? [])];
|
||||
if (row.assignee && !list.some((a) => a.id === row.assignee!.id)) {
|
||||
list.unshift(row.assignee);
|
||||
}
|
||||
return list;
|
||||
})(),
|
||||
createdBy: row.createdBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async normalizeAssigneeIds(
|
||||
organizationId: string,
|
||||
raw?: string[] | null,
|
||||
): Promise<string[]> {
|
||||
const ids = [
|
||||
...new Set((raw ?? []).map((id) => id.trim()).filter(Boolean)),
|
||||
];
|
||||
if (!ids.length) return [];
|
||||
const count = await this.prisma.user.count({
|
||||
where: { organizationId, id: { in: ids }, isActive: true },
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
throw new BadRequestException('یکی از مسئولان انتخابشده معتبر نیست');
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
|
||||
@@ -148,6 +148,11 @@ export class CreateActivityDto {
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateActivityDto {
|
||||
@@ -223,4 +228,9 @@ export class UpdateActivityDto {
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
@@ -76,17 +76,18 @@ export function projectCardAccessWhere(
|
||||
};
|
||||
}
|
||||
|
||||
/** فعالیتها بر اساس مسئول */
|
||||
/** فعالیتها بر اساس مسئول اصلی یا مسئولان مشترک */
|
||||
export function assigneeWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||
):
|
||||
| { assigneeId: string }
|
||||
| { assigneeId: { in: string[] } }
|
||||
| Record<string, never> {
|
||||
): Prisma.ActivityWhereInput {
|
||||
const ids = scopedOwnerIds(actor);
|
||||
if (!ids) return {};
|
||||
if (ids.length === 1) return { assigneeId: ids[0]! };
|
||||
return { assigneeId: { in: ids } };
|
||||
return {
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user