Accounts can be edited, including the opening balance, and finance sections and accounts can be granted on a role or on one user. The finance menu stays hidden when the organization module is disabled. Co-authored-by: Cursor <cursoragent@cursor.com>
459 lines
14 KiB
TypeScript
459 lines
14 KiB
TypeScript
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<OrgRoleDto[]> {
|
|
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<OrgRoleDto> {
|
|
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<OrgRoleDto> {
|
|
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<OrgRoleDto[]> {
|
|
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<UserRole, 'owner'> },
|
|
): 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<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' ||
|
|
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),
|
|
})),
|
|
};
|
|
}
|
|
}
|