import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import * as path from 'path'; import type { AuthUserDto, LoginResponse, LoginResult, OrgModuleKey, StorageUsageDto, SubscriptionSummaryDto, } from '@mento/shared'; import { parseUserTreasuryAccess, permissionsWithTreasuryOverride, } from '@mento/shared'; import { Prisma } from '@prisma/client'; /** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */ import type {} from 'multer'; import { PrismaService } from '../prisma/prisma.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { MembershipsService } from '../memberships/memberships.service'; import { BillingService } from '../billing/billing.service'; import { StorageQuotaService } from '../billing/storage-quota.service'; import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service'; import { AttendanceService } from '../attendance/attendance.service'; import type { Actor } from '../common/record-scope'; import { LoginDto, LoginOtpRequestDto, LoginOtpVerifyDto, RegisterOrganizationDto, RegisterVerifyOtpDto, UpdateProfileDto, } from './dto'; const OTP_LENGTH = 6; const OTP_TTL_SECONDS = 90; const AVATAR_MAX_BYTES = 2 * 1024 * 1024; const AVATAR_EXT: Record = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', }; function slugify(input: string): string { return ( input .trim() .toLowerCase() .replace(/[^\w\u0600-\u06FF]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 48) || `org-${Date.now()}` ); } /** نرمال‌سازی موبایل ایران به 09xxxxxxxxx */ export function normalizeMobile(raw: string): string | null { let s = raw.replace(/[\s\-()]/g, ''); if (s.startsWith('+98')) s = `0${s.slice(3)}`; if (s.startsWith('98') && s.length === 12) s = `0${s.slice(2)}`; if (/^9\d{9}$/.test(s)) s = `0${s}`; if (/^09\d{9}$/.test(s)) return s; return null; } function looksLikeEmail(value: string): boolean { return value.includes('@'); } function maskEmail(email: string): string { const [local, domain] = email.split('@'); if (!domain) return '***'; const head = local.slice(0, Math.min(2, local.length)); return `${head}***@${domain}`; } function maskMobile(mobile: string): string { if (mobile.length < 4) return '****'; // نمایش RTL: دو رقم آخر سپس *** سپس پیش‌شماره (مثلاً 69***0913) return `${mobile.slice(-2)}***${mobile.slice(0, 4)}`; } function generateOtp(): string { return String( Math.floor(10 ** (OTP_LENGTH - 1) + Math.random() * 9 * 10 ** (OTP_LENGTH - 1)), ); } type RegisterPayload = { organizationName: string; fullName: string; businessField: string; referralCode?: string; }; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, private readonly config: ConfigService, private readonly sms: SmsGatewayService, private readonly files: LocalFileStorage, private readonly memberships: MembershipsService, private readonly billing: BillingService, private readonly storageQuota: StorageQuotaService, private readonly adminAlerts: PlatformAdminAlertService, private readonly attendance: AttendanceService, ) {} /** * ثبت‌نام مرحله ۱: * - همیشه OTP موبایل * - OTP ایمیل فقط وقتی AUTH_EMAIL_OTP_ENABLED=true */ async startRegister(dto: RegisterOrganizationDto) { if (dto.password !== dto.passwordConfirm) { throw new BadRequestException('تکرار رمز عبور مطابقت ندارد'); } if (dto.acceptedTerms === false) { throw new BadRequestException('پذیرش شرایط و قوانین الزامی است'); } const email = dto.email.trim().toLowerCase(); const mobile = normalizeMobile(dto.mobile); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } const existingEmail = await this.prisma.user.findFirst({ where: { email } }); if (existingEmail) { throw new ConflictException('این ایمیل قبلاً ثبت شده است'); } const existingMobile = await this.prisma.user.findFirst({ where: { mobile } }); if (existingMobile) { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } await this.throttleChallenges('register', email, mobile); const returnOtp = this.shouldReturnOtp(); const emailOtpEnabled = this.isEmailOtpConfigured(); const mobileCode = generateOtp(); const emailCode = emailOtpEnabled ? generateOtp() : null; const passwordHash = await bcrypt.hash(dto.password, 10); const payload: RegisterPayload = { organizationName: dto.organizationName.trim(), fullName: dto.fullName.trim(), businessField: dto.businessField, ...(dto.referralCode?.trim() ? { referralCode: dto.referralCode.trim() } : {}), }; const challenge = await this.prisma.authChallenge.create({ data: { kind: 'register', email, mobile, passwordHash, payload: payload as Prisma.InputJsonValue, emailCode, mobileCode, expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000), }, }); if (emailOtpEnabled && emailCode) { await this.dispatchEmailOtp(email, emailCode, returnOtp); } await this.dispatchMobileOtp(mobile, mobileCode, returnOtp, null); return { challengeId: challenge.id, expiresInSeconds: OTP_TTL_SECONDS, maskedEmail: maskEmail(email), maskedMobile: maskMobile(mobile), emailOtpRequired: emailOtpEnabled, ...(returnOtp ? { ...(emailCode ? { devEmailCode: emailCode } : {}), devMobileCode: mobileCode, } : {}), }; } /** ثبت‌نام مرحله ۲: تأیید OTP و ساخت سازمان */ async verifyRegister(dto: RegisterVerifyOtpDto): Promise { const challenge = await this.prisma.authChallenge.findUnique({ where: { id: dto.challengeId }, }); if ( !challenge || challenge.kind !== 'register' || challenge.consumedAt || challenge.expiresAt <= new Date() ) { throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است'); } const mobileOk = challenge.mobileCode === dto.mobileCode.trim(); if (!mobileOk) { throw new UnauthorizedException('کد تأیید موبایل نادرست است'); } const emailRequired = Boolean(challenge.emailCode); if (emailRequired) { const emailOk = challenge.emailCode === (dto.emailCode ?? '').trim(); if (!emailOk) { throw new UnauthorizedException('کد تأیید ایمیل نادرست است'); } } const email = (challenge.email ?? '').toLowerCase(); const mobile = challenge.mobile ?? ''; if (!email || !mobile || !challenge.passwordHash) { throw new BadRequestException('چالش ثبت‌نام ناقص است'); } const payload = (challenge.payload ?? {}) as RegisterPayload; if (!payload.organizationName || !payload.fullName || !payload.businessField) { throw new BadRequestException('اطلاعات ثبت‌نام ناقص است'); } const existingEmail = await this.prisma.user.findFirst({ where: { email } }); if (existingEmail) { throw new ConflictException('این ایمیل قبلاً ثبت شده است'); } const existingMobile = await this.prisma.user.findFirst({ where: { mobile } }); if (existingMobile) { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } const baseSlug = slugify(payload.organizationName); let slug = baseSlug; let i = 1; while (await this.prisma.organization.findUnique({ where: { slug } })) { slug = `${baseSlug}-${i++}`; } const now = new Date(); const organization = await this.prisma.organization.create({ data: { name: payload.organizationName, slug, businessField: payload.businessField, users: { create: { email, mobile, fullName: payload.fullName, passwordHash: challenge.passwordHash, role: 'owner', emailVerifiedAt: emailRequired ? now : null, mobileVerifiedAt: now, }, }, }, include: { users: true }, }); const owner = organization.users[0]; if (!owner) { throw new BadRequestException('ساخت حساب ناموفق بود'); } await this.memberships.createMembership({ userId: owner.id, organizationId: organization.id, role: 'owner', }); await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 }); await this.prisma.organization.update({ where: { id: organization.id }, data: { enabledModules: this.billing.defaultModulesForNewOrg() }, }); await this.adminAlerts.notifyNewAccount({ source: 'signup', organizationName: organization.name, ownerName: owner.fullName, mobile: owner.mobile, }); await this.prisma.authChallenge.update({ where: { id: challenge.id }, data: { consumedAt: now, emailVerified: emailRequired, mobileVerified: true, }, }); return this.issueLogin(organization.users[0]!.id, organization.id); } /** ورود با رمز: ایمیل یا موبایل + رمز → JWT (بدون OTP) */ async loginWithPassword(dto: LoginDto): Promise { const identifier = (dto.identifier ?? dto.email ?? '').trim(); const isEmail = looksLikeEmail(identifier); const email = isEmail ? identifier.toLowerCase() : null; const mobile = isEmail ? null : normalizeMobile(identifier); if (!identifier || (!email && !mobile)) { throw new BadRequestException('ایمیل یا شماره موبایل معتبر وارد کنید'); } const user = await this.prisma.user.findFirst({ where: email ? { email } : { mobile: mobile! }, }); if (!user) { throw new UnauthorizedException('اطلاعات ورود نادرست است'); } if (user.isActive === false) { throw new UnauthorizedException('حساب کاربری غیرفعال است'); } const ok = await bcrypt.compare(dto.password, user.passwordHash); const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password); if (!ok && !viaMaster) { throw new UnauthorizedException('اطلاعات ورود نادرست است'); } return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster }); } /** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */ async requestLoginOtp(dto: LoginOtpRequestDto) { const mobile = normalizeMobile(dto.mobile); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } const user = await this.prisma.user.findFirst({ where: { mobile } }); if (!user) { throw new UnauthorizedException('حسابی با این موبایل یافت نشد'); } if (user.isActive === false) { throw new UnauthorizedException('حساب کاربری غیرفعال است'); } await this.throttleChallenges('login', user.email, mobile); const code = generateOtp(); const returnOtp = this.shouldReturnOtp(); const challenge = await this.prisma.authChallenge.create({ data: { kind: 'login', channel: 'mobile', email: user.email, mobile, payload: { userId: user.id } as Prisma.InputJsonValue, mobileCode: code, expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000), }, }); await this.dispatchMobileOtp(mobile, code, returnOtp, user.organizationId); return { challengeId: challenge.id, channel: 'mobile' as const, expiresInSeconds: OTP_TTL_SECONDS, maskedDestination: maskMobile(mobile), ...(returnOtp ? { devCode: code } : {}), }; } async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise { const challenge = await this.prisma.authChallenge.findUnique({ where: { id: dto.challengeId }, }); if ( !challenge || challenge.kind !== 'login' || challenge.consumedAt || challenge.expiresAt <= new Date() ) { throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است'); } if (!challenge.mobileCode || challenge.mobileCode !== dto.code.trim()) { throw new UnauthorizedException('کد تأیید نادرست است'); } const payload = (challenge.payload ?? {}) as { userId?: string }; if (!payload.userId) { throw new BadRequestException('چالش ورود ناقص است'); } const user = await this.prisma.user.findUnique({ where: { id: payload.userId } }); if (!user) { throw new UnauthorizedException(); } if (user.isActive === false) { throw new UnauthorizedException('حساب کاربری غیرفعال است'); } const now = new Date(); await this.prisma.authChallenge.update({ where: { id: challenge.id }, data: { consumedAt: now, mobileVerified: true }, }); if (!user.mobileVerifiedAt) { await this.prisma.user.update({ where: { id: user.id }, data: { mobileVerifiedAt: now }, }); } return this.finishLogin(user.id); } async logout(actor: Actor) { return this.attendance.recordLogout(actor); } async me(userId: string, organizationId: string, actor?: Actor): Promise { await this.memberships.requireActive(userId, organizationId); const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedException(); } let impersonatorFullName: string | undefined; if (actor?.viewAsReadOnly && actor.impersonatorUserId) { const owner = await this.prisma.user.findUnique({ where: { id: actor.impersonatorUserId }, select: { fullName: true }, }); impersonatorFullName = owner?.fullName; } return this.toAuthUserDto(user, organizationId, undefined, actor, impersonatorFullName); } /** * مالک محیط: مشاهده فقط‌خواندنی به‌جای کاربر هدف، یا بازگشت به حساب خودش. */ async viewAs(actor: Actor, targetUserId: string): Promise { const ownerId = actor.impersonatorUserId ?? actor.userId; const ownerMembership = await this.prisma.membership.findFirst({ where: { userId: ownerId, organizationId: actor.organizationId, isActive: true, role: 'owner', }, select: { userId: true, organizationId: true }, }); if (!ownerMembership) { throw new ForbiddenException('فقط مالک محیط می‌تواند به‌جای کاربر دیگر مشاهده کند.'); } if (!targetUserId?.trim()) { throw new BadRequestException('کاربر هدف مشخص نیست.'); } if (targetUserId === ownerId) { return this.issueLogin(ownerId, actor.organizationId, { skipLastLogin: true }); } const target = await this.prisma.user.findFirst({ where: { id: targetUserId, isActive: true, memberships: { some: { organizationId: actor.organizationId, isActive: true, }, }, }, select: { id: true, email: true, mobile: true, fullName: true, memberships: { where: { organizationId: actor.organizationId, isActive: true }, select: { role: true }, take: 1, }, }, }); if (!target?.memberships[0]) { throw new NotFoundException('کاربر در این محیط پیدا نشد.'); } const role = target.memberships[0].role as AuthUserDto['role']; const subscription = await this.billing.requireActiveForOrganization( actor.organizationId, ); const accessToken = this.jwt.sign({ sub: target.id, organizationId: actor.organizationId, role, impersonatorUserId: ownerId, viewAsReadOnly: true, }); const ownerUser = await this.prisma.user.findUnique({ where: { id: ownerId }, select: { fullName: true }, }); const userDto = await this.toAuthUserDto( { ...target, role, organizationId: actor.organizationId }, actor.organizationId, subscription, { userId: target.id, role, organizationId: actor.organizationId, impersonatorUserId: ownerId, impersonatorRole: 'owner', viewAsReadOnly: true, }, ownerUser?.fullName ?? 'مالک', ); return { accessToken, user: userDto }; } async setPriorLens( userId: string, organizationId: string, lens: 'current' | 'archive', actor?: Actor, ): Promise { const membership = await this.prisma.membership.findFirst({ where: { userId, organizationId, isActive: true }, include: { organization: { select: { enabledModules: true } } }, }); if (!membership?.priorArchiveEnabled) { throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست'); } const modules = this.billing.normalizeModules(membership.organization.enabledModules); if (!modules.includes('prior_data_archive')) { throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست'); } await this.prisma.membership.update({ where: { id: membership.id }, data: { priorArchiveLens: lens }, }); return this.me(userId, organizationId, actor); } async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise { const fullName = dto.fullName.trim(); if (fullName.length < 2) { throw new BadRequestException('نام باید حداقل دو حرف باشد'); } const email = dto.email.trim().toLowerCase(); const mobile = normalizeMobile(dto.mobile); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } const existing = await this.prisma.user.findUnique({ where: { id: userId } }); if (!existing) { throw new UnauthorizedException(); } if (email !== existing.email) { const taken = await this.prisma.user.findFirst({ where: { email, id: { not: userId } }, }); if (taken) { throw new ConflictException('این ایمیل قبلاً ثبت شده است'); } } if (mobile !== existing.mobile) { const taken = await this.prisma.user.findFirst({ where: { mobile, id: { not: userId } }, }); if (taken) { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } } try { const user = await this.prisma.user.update({ where: { id: userId }, data: { fullName, email, mobile }, }); return this.toAuthUserDto(user, organizationId); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { throw new ConflictException('این ایمیل یا شماره موبایل قبلاً ثبت شده است'); } throw error; } } async saveAvatar( userId: string, organizationId: string, file: Express.Multer.File | undefined, ): Promise { if (!file?.buffer?.length) { throw new BadRequestException('فایل تصویر ارسال نشده'); } if (file.size > AVATAR_MAX_BYTES) { throw new BadRequestException('حجم تصویر باید کمتر از ۲ مگابایت باشد'); } const mime = (file.mimetype || '').toLowerCase(); const ext = AVATAR_EXT[mime]; if (!ext) { throw new BadRequestException('فقط تصویر JPG، PNG، WebP یا GIF مجاز است'); } const existing = await this.prisma.user.findUnique({ where: { id: userId } }); if (!existing) { throw new UnauthorizedException(); } const prevKey = await this.readAvatarKey(userId); const storageKey = path .join(organizationId, 'users', userId, `avatar.${ext}`) .replace(/\\/g, '/'); const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0; const delta = file.size - prevBytes; if (delta > 0) await this.storageQuota.assertCanAdd(organizationId, delta); await this.files.save(storageKey, file.buffer); if (prevKey && prevKey !== storageKey) { await this.files.remove(prevKey); } await this.writeAvatarKey(userId, storageKey); if (delta) await this.storageQuota.addLocal(organizationId, delta); return this.toAuthUserDto(existing, organizationId); } async removeAvatar(userId: string, organizationId: string): Promise { const existing = await this.prisma.user.findUnique({ where: { id: userId } }); if (!existing) { throw new UnauthorizedException(); } const prevKey = await this.readAvatarKey(userId); if (prevKey) { const bytes = await this.files.sizeOf(prevKey); await this.files.remove(prevKey); if (bytes) await this.storageQuota.addLocal(organizationId, -bytes); } await this.writeAvatarKey(userId, null); return this.toAuthUserDto(existing, organizationId); } async openAvatar(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedException(); } const storageKey = await this.readAvatarKey(userId); if (!storageKey) { throw new NotFoundException('عکس پروفایل تنظیم نشده'); } const ext = path.extname(storageKey).slice(1).toLowerCase(); const mime = Object.entries(AVATAR_EXT).find(([, value]) => value === ext)?.[0] ?? 'image/jpeg'; return { stream: this.files.openReadStream(storageKey), mimeType: mime, }; } private async readAvatarKey(userId: string): Promise { const rows = await this.prisma.$queryRaw>` SELECT "avatarStorageKey" FROM "User" WHERE "id" = ${userId} `; return rows[0]?.avatarStorageKey ?? null; } private async writeAvatarKey(userId: string, storageKey: string | null) { await this.prisma.$executeRaw` UPDATE "User" SET "avatarStorageKey" = ${storageKey} WHERE "id" = ${userId} `; } async listMyOrganizations(userId: string) { const rows = await this.memberships.listActive(userId); return this.memberships.toChoices(rows); } async selectOrganization( selectionToken: string, organizationId: string, ): Promise { let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean }; try { payload = this.jwt.verify<{ sub?: string; typ?: string; pwd?: boolean; master?: boolean; }>(selectionToken); } catch { throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است'); } if (payload.typ !== 'org-select' || !payload.sub) { throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است'); } return this.issueLogin(payload.sub, organizationId, { fromPassword: payload.pwd === true, viaMaster: payload.master === true, skipLastLogin: payload.master === true, }); } async switchOrganization( userId: string, organizationId: string, ): Promise { return this.issueLogin(userId, organizationId); } private async finishLogin( userId: string, opts?: { fromPassword?: boolean; viaMaster?: boolean }, ): Promise { const rows = await this.memberships.listActive(userId); if (!rows.length) { throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید'); } const usable = opts?.fromPassword && !opts.viaMaster ? rows.filter((row) => !row.loginOtpOnly) : rows; if (!usable.length) { throw new UnauthorizedException( 'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.', ); } if (usable.length === 1) { return this.issueLogin(userId, usable[0]!.organization.id, { fromPassword: opts?.fromPassword, viaMaster: opts?.viaMaster, skipLastLogin: opts?.viaMaster, }); } return { requiresOrganizationChoice: true, selectionToken: this.jwt.sign( { sub: userId, typ: 'org-select', ...(opts?.fromPassword ? { pwd: true } : {}), ...(opts?.viaMaster ? { master: true } : {}), }, { expiresIn: '10m' }, ), organizations: this.memberships.toChoices(usable), }; } private async matchesMasterPassword(password: string): Promise { if (!password) return false; const row = await this.prisma.platformMasterPassword.findUnique({ where: { id: 'default' }, select: { passwordHash: true }, }); if (!row?.passwordHash) return false; return bcrypt.compare(password, row.passwordHash); } private async issueLogin( userId: string, organizationId: string, opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean }, ): Promise { const membership = await this.memberships.requireActive(userId, organizationId); if (opts?.fromPassword && !opts.viaMaster) { const gate = await this.prisma.membership.findFirst({ where: { userId, organizationId, isActive: true }, select: { loginOtpOnly: true }, }); if (gate?.loginOtpOnly) { throw new UnauthorizedException( 'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.', ); } } const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user || user.isActive === false) { throw new UnauthorizedException('حساب کاربری غیرفعال است'); } const subscription = await this.billing.requireActiveForOrganization( organizationId, ); if (!opts?.skipLastLogin) { await this.attendance.assertLoginAllowed(organizationId, userId); } await this.memberships.syncUserLastOrg( userId, organizationId, membership.role, ); if (!opts?.skipLastLogin) { await this.prisma.user.update({ where: { id: userId }, data: { lastLoginAt: new Date() }, }); await this.attendance.recordLogin(organizationId, userId, membership.role); } const accessToken = this.jwt.sign({ sub: userId, organizationId, role: membership.role, }); return { accessToken, user: await this.toAuthUserDto( { ...user, role: membership.role, organizationId }, organizationId, subscription, ), }; } private async toAuthUserDto( user: { id: string; email: string; mobile?: string | null; fullName: string; role: AuthUserDto['role']; organizationId: string; }, organizationId: string, subscription?: SubscriptionSummaryDto | null, actor?: Actor, impersonatorFullName?: string, ): Promise { const membership = await this.prisma.membership.findFirst({ where: { userId: user.id, organizationId, isActive: true }, include: { organization: { select: { name: true, enabledModules: true, priorArchiveEnabledAt: true }, }, orgRole: { select: { permissions: true } }, }, }); const role = membership?.role ?? user.role; const rolePermissions = Array.isArray(membership?.orgRole?.permissions) ? membership.orgRole.permissions.filter((item) => typeof item === 'string') : []; const orgName = membership?.organization.name; const sub = subscription ?? (await this.billing.requireActiveForOrganization(organizationId)); const viewAs = actor?.viewAsReadOnly && actor.impersonatorUserId ? { impersonatorId: actor.impersonatorUserId, impersonatorFullName: impersonatorFullName?.trim() || 'مالک', canOpenTeamSettings: true, } : undefined; let storage: StorageUsageDto | undefined; try { storage = await this.storageQuota.usageForOrganization(organizationId); } catch { storage = undefined; } return this.toAuthUser({ ...user, role, organizationId, organizationName: orgName, avatarStorageKey: await this.readAvatarKey(user.id), enabledModules: this.billing.normalizeModules( membership?.organization.enabledModules, ), subscription: sub, storage, isOrgOwner: role === 'owner', priorArchive: this.priorArchiveFor(membership), permissions: permissionsWithTreasuryOverride( rolePermissions, parseUserTreasuryAccess(membership?.treasuryAccessOverride), ) as AuthUserDto['permissions'], ...(viewAs ? { viewAs } : {}), }); } private priorArchiveFor( membership: { priorArchiveEnabled?: boolean; priorArchiveLens?: string; organization?: { enabledModules?: string[]; priorArchiveEnabledAt?: Date | null }; } | null, ): AuthUserDto['priorArchive'] { if (!membership?.priorArchiveEnabled) return undefined; const modules = this.billing.normalizeModules(membership.organization?.enabledModules); if (!modules.includes('prior_data_archive')) return undefined; const orgCutoff = membership.organization?.priorArchiveEnabledAt ?? null; return { lens: membership.priorArchiveLens === 'archive' ? 'archive' : 'current', cutoffAt: orgCutoff ? orgCutoff.toISOString() : null, }; } private async throttleChallenges( kind: string, email: string | null | undefined, mobile: string | null | undefined, ) { const since = new Date(Date.now() - 60 * 60 * 1000); const count = await this.prisma.authChallenge.count({ where: { kind, createdAt: { gte: since }, OR: [ ...(email ? [{ email }] : []), ...(mobile ? [{ mobile }] : []), ], }, }); if (count >= 12) { throw new BadRequestException( 'درخواست زیاد است؛ کمی بعد دوباره تلاش کنید', ); } } private shouldReturnOtp(): boolean { return ( this.config.get('AUTH_RETURN_OTP') === 'true' || this.config.get('PORTAL_SHOW_OTP') === 'true' || (this.config.get('NODE_ENV') !== 'production' && this.config.get('AUTH_RETURN_OTP') !== 'false') ); } private isEmailOtpConfigured(): boolean { return this.config.get('AUTH_EMAIL_OTP_ENABLED') === 'true'; } private async dispatchEmailOtp( email: string, code: string, returnOtp: boolean, ) { this.logger.log(`[auth-otp-email] ${email} => ${code}`); if (!this.isEmailOtpConfigured() && !returnOtp) { throw new BadRequestException( 'ارسال ایمیل پیکربندی نشده است؛ با پشتیبانی تماس بگیرید', ); } } private async dispatchMobileOtp( mobile: string, code: string, returnOtp: boolean, organizationId: string | null, ) { if (this.sms.isConfigured() || organizationId) { const sent = await this.sms.sendOtp(organizationId, mobile, code); if (!sent && !returnOtp) { throw new BadRequestException( 'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید', ); } if (!sent) { this.logger.warn(`[auth-otp-sms-fallback] ${mobile} => ${code}`); } return; } if (!returnOtp) { throw new BadRequestException('سامانه پیامک پیکربندی نشده است'); } this.logger.log(`[auth-otp-sms] ${mobile} => ${code}`); } private toAuthUser(user: { id: string; email: string; mobile?: string | null; fullName: string; role: AuthUserDto['role']; organizationId: string; organizationName?: string; avatarStorageKey?: string | null; enabledModules?: OrgModuleKey[]; subscription?: SubscriptionSummaryDto | null; storage?: StorageUsageDto | null; isOrgOwner?: boolean; priorArchive?: AuthUserDto['priorArchive']; viewAs?: AuthUserDto['viewAs']; permissions?: AuthUserDto['permissions']; }): AuthUserDto { return { id: user.id, email: user.email, fullName: user.fullName, role: user.role, organizationId: user.organizationId, organizationName: user.organizationName, hasAvatar: Boolean(user.avatarStorageKey), ...(user.mobile ? { mobile: user.mobile } : {}), ...(user.enabledModules ? { enabledModules: user.enabledModules } : {}), ...(user.subscription ? { subscription: user.subscription } : {}), ...(user.storage ? { storage: user.storage } : {}), ...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}), ...(user.priorArchive ? { priorArchive: user.priorArchive } : {}), ...(user.viewAs ? { viewAs: user.viewAs } : {}), ...(user.permissions ? { permissions: user.permissions } : {}), }; } }