import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import type { DataScope, DataVisibility, InviteUserRequest, OrgUserDto, UpdateOrgUserRequest, UpdateUserVisibilityRequest, UserRole, } from '@mento/shared'; import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared'; import { resolveAllSectionAccess } from '../common/data-access'; import { parseVisibilityScopes } from '../common/record-scope'; 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 { BillingService } from '../billing/billing.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; dataVisibility?: string | null; dataVisibilityScopes?: unknown; dataAccessOverride?: unknown; priorArchiveEnabled?: boolean; logoutTime?: string | null; blockLoginAfterLogout?: boolean; loginOtpOnly?: boolean; createdAt: Date; orgRole: { id: string; name: string; color: string; dataAccessPolicy?: unknown } | 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, private readonly billing: BillingService, ) {} 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); } await this.billing.assertSeatAvailable(organizationId, null); 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 updateProfile( organizationId: string, actorRole: UserRole, targetId: string, dto: UpdateOrgUserRequest, ): Promise { if (actorRole !== 'owner' && actorRole !== 'admin') { throw new ForbiddenException('فقط مالک یا مدیر می‌تواند مشخصات کاربر را ویرایش کند'); } const target = await this.memberships.findUserInOrgById( organizationId, targetId, ); if (target.role === 'owner' && actorRole !== 'owner') { throw new ForbiddenException('مدیر نمی‌تواند مشخصات مالک را تغییر دهد'); } const fullName = dto.fullName.trim(); if (fullName.length < 2) { throw new BadRequestException('نام باید حداقل دو حرف باشد'); } const mobile = normalizeMobile(dto.mobile); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } const previousMobile = target.user.mobile ?? ''; if (mobile !== previousMobile) { const taken = await this.prisma.user.findFirst({ where: { mobile, id: { not: targetId } }, select: { id: true }, }); if (taken) { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } } const data: Prisma.UserUpdateInput = { fullName, mobile }; const prevEmail = target.user.email; const wasSynthetic = prevEmail.endsWith('@users.mento.local') || prevEmail === inviteEmailFromMobile(previousMobile); if (wasSynthetic && mobile !== previousMobile) { const nextEmail = inviteEmailFromMobile(mobile); const emailTaken = await this.prisma.user.findFirst({ where: { email: nextEmail, id: { not: targetId } }, select: { id: true }, }); if (!emailTaken) data.email = nextEmail; } const password = dto.password?.trim() ?? ''; if (password) { if (password.length < 8) { throw new BadRequestException('رمز عبور باید حداقل ۸ کاراکتر باشد'); } data.passwordHash = await bcrypt.hash(password, 10); } try { await this.prisma.user.update({ where: { id: targetId }, data, }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('این شماره موبایل یا ایمیل قبلاً ثبت شده است'); } throw e; } return this.dtoOf(organizationId, targetId); } 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 updateLogoutTime( organizationId: string, actorRole: UserRole, targetId: string, logoutTime: string | null, blockLoginAfterLogout?: boolean, ): Promise { if (actorRole !== 'owner') { throw new ForbiddenException('فقط مالک می‌تواند ساعت خروج را تعیین کند'); } const target = await this.memberships.findUserInOrgById(organizationId, targetId); if (!target) throw new NotFoundException('کاربر پیدا نشد'); if (target.role === 'owner') { throw new BadRequestException('برای مالک ساعت خروج تعیین نمی‌شود'); } const value = logoutTime?.trim() || null; if (value && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) { throw new BadRequestException('ساعت خروج معتبر نیست'); } await this.prisma.membership.update({ where: { id: target.id }, data: { logoutTime: value, // بدون ساعت خروج، مسدودسازی معنی ندارد ...(!value ? { blockLoginAfterLogout: false } : blockLoginAfterLogout !== undefined ? { blockLoginAfterLogout } : {}), }, }); return this.dtoOf(organizationId, targetId); } async updatePriorArchive( organizationId: string, actorRole: UserRole, targetId: string, enabled: boolean, ): Promise { if (actorRole !== 'owner') { throw new ForbiddenException('فقط مالک می‌تواند بایگانی اطلاعات قبل را برای کاربران روشن کند'); } const target = await this.memberships.findUserInOrgById(organizationId, targetId); if (enabled) { const org = await this.prisma.organization.findUnique({ where: { id: organizationId }, select: { enabledModules: true }, }); if (!org?.enabledModules?.includes('prior_data_archive')) { throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست'); } } await this.prisma.membership.update({ where: { id: target.id }, data: { priorArchiveEnabled: enabled }, }); return this.dtoOf(organizationId, targetId); } async updateVisibility( organizationId: string, actorRole: UserRole, targetId: string, dto: UpdateUserVisibilityRequest, ): Promise { if (actorRole !== 'owner' && actorRole !== 'admin') { throw new ForbiddenException('اجازه تغییر سطح دسترسی به پرونده‌ها را ندارید'); } const { dataVisibility, scopes } = dto; if (dataVisibility === undefined && !scopes) { throw new BadRequestException('تغییری ارسال نشده است'); } if (dataVisibility !== undefined && !DATA_VISIBILITIES.includes(dataVisibility)) { throw new BadRequestException('سطح دسترسی به پرونده‌ها نامعتبر است'); } if (scopes) { for (const [key, value] of Object.entries(scopes)) { if (!DATA_SCOPES.includes(key as DataScope)) { throw new BadRequestException(`بخش نامعتبر: ${key}`); } if (value !== null && !DATA_VISIBILITIES.includes(value as DataVisibility)) { throw new BadRequestException('سطح دسترسی به پرونده‌ها نامعتبر است'); } } } const target = await this.memberships.findUserInOrgById( organizationId, targetId, ); if (target.role === 'owner' || target.role === 'admin') { throw new BadRequestException( 'مالک و مدیر همیشه همه پرونده‌ها را می‌بینند', ); } const data: Prisma.MembershipUpdateInput = {}; if (dataVisibility !== undefined) data.dataVisibility = dataVisibility; if (scopes) { const current = await this.prisma.membership.findUnique({ where: { id: target.id }, select: { dataVisibilityScopes: true }, }); const merged = parseVisibilityScopes(current?.dataVisibilityScopes); for (const [key, value] of Object.entries(scopes)) { if (value === null) delete merged[key as DataScope]; else merged[key as DataScope] = value; } data.dataVisibilityScopes = merged; } await this.prisma.membership.update({ where: { id: target.id }, data, }); return this.dtoOf(organizationId, targetId); } async updateDataAccess( organizationId: string, actorRole: UserRole, targetId: string, dto: { dataAccessOverride?: Record | null }, ): Promise { if (actorRole !== 'owner' && actorRole !== 'admin') { throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید'); } if (dto.dataAccessOverride === undefined) { throw new BadRequestException('تغییری ارسال نشده است'); } const target = await this.memberships.findUserInOrgById(organizationId, targetId); if (target.role === 'owner' || target.role === 'admin') { throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند'); } const next = dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride); await this.prisma.membership.update({ where: { id: target.id }, data: { dataAccessOverride: next }, }); 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 updateLoginOtpOnly( organizationId: string, actor: { userId: string; role: UserRole }, targetId: string, loginOtpOnly: boolean, ): Promise { if (actor.role !== 'owner' && actor.role !== 'admin') { throw new ForbiddenException('فقط مالک یا مدیر می‌تواند شیوهٔ ورود را تغییر دهد'); } const target = await this.memberships.findUserInOrgById(organizationId, targetId); if (target.role === 'owner') { throw new BadRequestException('برای مالک نمی‌توان ورود فقط با کد را فعال کرد'); } if (loginOtpOnly) { const mobile = target.user.mobile?.trim(); if (!mobile) { throw new BadRequestException('برای ورود با کد، کاربر باید موبایل داشته باشد'); } } await this.prisma.membership.update({ where: { id: target.id }, data: { loginOtpOnly }, }); 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('حداقل یک مالک فعال لازم است'); } } if (isActive && !target.isActive) { await this.billing.assertSeatAvailable(organizationId, targetId); } 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 { await this.billing.assertSeatAvailable(organizationId, userId); 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), dataVisibility: row.role === 'owner' || row.role === 'admin' ? 'org' : DATA_VISIBILITIES.includes(row.dataVisibility as DataVisibility) ? (row.dataVisibility as DataVisibility) : 'own', dataVisibilityScopes: row.role === 'owner' || row.role === 'admin' ? {} : parseVisibilityScopes(row.dataVisibilityScopes), dataAccessOverride: row.role === 'owner' || row.role === 'admin' ? {} : parseDataAccessPolicy(row.dataAccessOverride), ...(() => { const effectiveDataAccess = resolveAllSectionAccess({ role: row.role, rolePolicy: row.orgRole?.dataAccessPolicy, override: row.dataAccessOverride, legacyVisibility: row.dataVisibility, legacyScopes: parseVisibilityScopes(row.dataVisibilityScopes), }); return { effectiveDataAccess, effectiveCustomersAccess: effectiveDataAccess.customers, }; })(), priorArchiveEnabled: Boolean(row.priorArchiveEnabled), logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null, blockLoginAfterLogout: row.role !== 'owner' && Boolean(row.logoutTime) && Boolean(row.blockLoginAfterLogout), loginOtpOnly: row.role === 'owner' ? false : Boolean(row.loginOtpOnly), createdAt: row.createdAt.toISOString(), }; } }