import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared'; import * as bcrypt from 'bcryptjs'; import { Prisma } from '@prisma/client'; import { normalizeMobile } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { OrgRolesService } from '../org-roles/org-roles.service'; import { MembershipsService } from '../memberships/memberships.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import * as path from 'path'; function inviteEmailFromMobile(mobile: string): string { return `${mobile}@users.mento.local`; } const AVATAR_EXT: Record = { jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', }; type MembershipRow = { role: UserRole; orgRoleId: string | null; isActive: boolean; commissionPercent: { toString(): string } | number | null; createdAt: Date; orgRole: { id: string; name: string; color: string } | null; user: { id: string; email: string; mobile: string | null; fullName: string; lastLoginAt: Date | null; avatarStorageKey: string | null; organizationId: string; }; }; @Injectable() export class UsersService { constructor( private readonly prisma: PrismaService, private readonly orgRoles: OrgRolesService, private readonly memberships: MembershipsService, private readonly files: LocalFileStorage, ) {} async list(organizationId: string): Promise { await this.orgRoles.ensureDefaultRoles(organizationId); const rows = await this.memberships.listInOrg(organizationId); return rows.map((row) => this.toDto(row)); } async invite( organizationId: string, actorRole: UserRole, dto: InviteUserRequest, ): Promise { if (actorRole !== 'owner' && actorRole !== 'admin') { throw new ForbiddenException('فقط مالک یا مدیر می‌تواند کاربر اضافه کند'); } const mobile = normalizeMobile(dto.mobile); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } const resolved = await this.orgRoles.resolveInviteRole(organizationId, { orgRoleId: dto.orgRoleId, role: dto.role, }); if (resolved.role === 'owner' && actorRole !== 'owner') { throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند'); } const existingUser = await this.prisma.user.findFirst({ where: { mobile } }); if (existingUser) { return this.addExistingUser(organizationId, existingUser.id, resolved); } const password = dto.password?.trim() ?? ''; if (password.length < 8) { throw new BadRequestException( 'برای کاربر جدید رمز عبور حداقل ۸ کاراکتر لازم است', ); } const email = inviteEmailFromMobile(mobile); const existingEmail = await this.prisma.user.findFirst({ where: { email } }); if (existingEmail) { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } const passwordHash = await bcrypt.hash(password, 10); try { const created = await this.prisma.user.create({ data: { organizationId, email, mobile, fullName: dto.fullName.trim(), passwordHash, role: resolved.role, orgRoleId: resolved.orgRoleId, isActive: true, }, }); await this.memberships.createMembership({ userId: created.id, organizationId, role: resolved.role, orgRoleId: resolved.orgRoleId, }); return this.dtoOf(organizationId, created.id); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } throw e; } } async updateCommission( organizationId: string, actorRole: UserRole, targetId: string, percent: number, ): Promise { if (actorRole !== 'owner' && actorRole !== 'admin') { throw new ForbiddenException('فقط مالک یا مدیر می‌تواند پورسانت را تعیین کند'); } if (percent < 0 || percent > 100) { throw new BadRequestException('درصد پورسانت باید بین ۰ تا ۱۰۰ باشد'); } const target = await this.memberships.findUserInOrgById(organizationId, targetId); await this.prisma.membership.update({ where: { id: target.id }, data: { commissionPercent: new Prisma.Decimal(percent) }, }); if (target.user.organizationId === organizationId) { await this.prisma.user.update({ where: { id: target.user.id }, data: { commissionPercent: new Prisma.Decimal(percent) }, }); } return this.dtoOf(organizationId, targetId); } async updateRole( organizationId: string, actor: { userId: string; role: UserRole }, targetId: string, dto: { role?: UserRole; orgRoleId?: string }, ): Promise { if (actor.role !== 'owner' && actor.role !== 'admin') { throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش را تغییر دهد'); } const target = await this.memberships.findUserInOrgById(organizationId, targetId); let nextRole = dto.role ?? target.role; let nextOrgRoleId = dto.orgRoleId ?? target.orgRoleId; if (dto.orgRoleId) { const orgRole = await this.prisma.orgRole.findFirst({ where: { id: dto.orgRoleId, organizationId }, }); if (!orgRole) throw new BadRequestException('نقش انتخاب‌شده معتبر نیست'); nextOrgRoleId = orgRole.id; nextRole = orgRole.systemKey ? (['owner', 'admin', 'sales', 'support'].includes(orgRole.systemKey) ? (orgRole.systemKey as UserRole) : 'sales') : 'sales'; } else if (dto.role) { await this.orgRoles.ensureDefaultRoles(organizationId); const orgRole = await this.prisma.orgRole.findFirst({ where: { organizationId, systemKey: dto.role }, }); nextOrgRoleId = orgRole?.id ?? nextOrgRoleId; } if (actor.role === 'admin') { if (target.role === 'owner' || nextRole === 'owner') { throw new ForbiddenException('مدیر نمی‌تواند نقش مالک را تغییر دهد'); } } if (target.role === 'owner' && nextRole !== 'owner') { const owners = await this.memberships.countActiveOwners( organizationId, target.userId, ); if (owners < 1) { throw new BadRequestException( 'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید', ); } } await this.prisma.membership.update({ where: { id: target.id }, data: { role: nextRole, orgRoleId: nextOrgRoleId }, }); if (target.user.organizationId === organizationId) { await this.memberships.syncUserLastOrg( target.userId, organizationId, nextRole, nextOrgRoleId, ); } return this.dtoOf(organizationId, targetId); } async updateStatus( organizationId: string, actor: { userId: string; role: UserRole }, targetId: string, isActive: boolean, ): Promise { if (actor.role !== 'owner' && actor.role !== 'admin') { throw new ForbiddenException('فقط مالک یا مدیر می‌تواند وضعیت کاربر را تغییر دهد'); } if (actor.userId === targetId && !isActive) { throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید'); } const target = await this.memberships.findUserInOrgById(organizationId, targetId); if (target.role === 'owner' && !isActive && actor.role !== 'owner') { throw new ForbiddenException('مدیر نمی‌تواند مالک را غیرفعال کند'); } if (target.role === 'owner' && !isActive) { const activeOwners = await this.memberships.countActiveOwners( organizationId, targetId, ); if (activeOwners < 1) { throw new BadRequestException('حداقل یک مالک فعال لازم است'); } } await this.prisma.membership.update({ where: { id: target.id }, data: { isActive }, }); return this.dtoOf(organizationId, targetId); } async openAvatar(organizationId: string, userId: string) { const membership = await this.memberships.findInOrg(organizationId, userId); if (!membership) throw new NotFoundException('کاربر پیدا نشد'); const key = membership.user.avatarStorageKey; if (!key) { throw new NotFoundException('عکس پروفایل تنظیم نشده'); } const ext = path.extname(key).slice(1).toLowerCase(); const mime = AVATAR_EXT[ext] ?? 'image/jpeg'; return { stream: this.files.openReadStream(key), mimeType: mime, }; } private async addExistingUser( organizationId: string, userId: string, resolved: { role: UserRole; orgRoleId: string }, ): Promise { const existing = await this.prisma.membership.findFirst({ where: { userId, organizationId }, }); if (existing?.isActive) { throw new ConflictException('این کاربر هم‌اکنون عضو این محیط است'); } if (existing) { await this.prisma.membership.update({ where: { id: existing.id }, data: { isActive: true, role: resolved.role, orgRoleId: resolved.orgRoleId, }, }); return this.dtoOf(organizationId, userId); } try { await this.memberships.createMembership({ userId, organizationId, role: resolved.role, orgRoleId: resolved.orgRoleId, }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('این کاربر هم‌اکنون عضو این محیط است'); } throw e; } return this.dtoOf(organizationId, userId); } private async dtoOf(organizationId: string, userId: string): Promise { const row = await this.memberships.findUserInOrgById(organizationId, userId); return this.toDto(row); } private toDto(row: MembershipRow & { userId?: string }): OrgUserDto { return { id: row.user.id, email: row.user.email, mobile: row.user.mobile ?? null, fullName: row.user.fullName, role: row.role, orgRoleId: row.orgRoleId, orgRoleName: row.orgRole?.name, orgRoleColor: row.orgRole?.color ?? this.orgRoles.defaultColorFor(row.role), isActive: row.isActive, lastLoginAt: row.user.lastLoginAt ? row.user.lastLoginAt.toISOString() : null, hasAvatar: Boolean(row.user.avatarStorageKey), commissionPercent: Number(row.commissionPercent ?? 0), createdAt: row.createdAt.toISOString(), }; } }