import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import type { PlatformAdminDto, PlatformOrganizationDto, PlatformStatsDto, PlatformUserDto, ProductPlanCode, UserRole, } from '@mento/shared'; import { isBusinessField } from '@mento/shared'; import * as bcrypt from 'bcryptjs'; import { Prisma } from '@prisma/client'; import { normalizeMobile } from '../auth/auth.service'; import { MembershipsService } from '../memberships/memberships.service'; import { OrgRolesService } from '../org-roles/org-roles.service'; import { PipelinesService } from '../pipelines/pipelines.service'; import { BillingService, toSummary } from '../billing/billing.service'; import { StorageQuotaService } from '../billing/storage-quota.service'; import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service'; import { PrismaService } from '../prisma/prisma.service'; import { buildSearchWhere, ci } from '../common/search'; import type { ApplyBillingPlanDto, CreatePlatformAdminDto, CreatePlatformOrganizationDto, ResetPlatformAdminPasswordDto, ResetPlatformUserPasswordDto, UpdateOrgModulesDto, UpdatePlatformOrganizationDto, } from './dto'; function slugify(input: string): string { return ( input .trim() .toLowerCase() .replace(/[^\w\u0600-\u06FF]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 48) || `org-${Date.now()}` ); } @Injectable() export class PlatformService { constructor( private readonly prisma: PrismaService, private readonly memberships: MembershipsService, private readonly orgRoles: OrgRolesService, private readonly pipelines: PipelinesService, private readonly billing: BillingService, private readonly storage: StorageQuotaService, private readonly adminAlerts: PlatformAdminAlertService, ) {} async stats(): Promise { const [ organizationCount, activeOrganizationCount, userCount, activeUserCount, membershipCount, ] = await this.prisma.$transaction([ this.prisma.organization.count(), this.prisma.organization.count({ where: { isActive: true } }), this.prisma.user.count(), this.prisma.user.count({ where: { isActive: true } }), this.prisma.membership.count(), ]); const [expiringSoonCount, expiredBillingCount] = await Promise.all([ this.billing.countExpiringSoon(), this.billing.countExpired(), ]); return { organizationCount, activeOrganizationCount, userCount, activeUserCount, membershipCount, expiringSoonCount, expiredBillingCount, }; } async listOrganizations(query?: string): Promise { const rows = await this.prisma.organization.findMany({ where: buildSearchWhere(query, (t) => [ { name: ci(t) }, { slug: ci(t) }, ]), orderBy: { createdAt: 'desc' }, select: { id: true, name: true, slug: true, businessField: true, isActive: true, createdAt: true, enabledModules: true, _count: { select: { memberships: true, people: true, deals: true, }, }, memberships: { where: { role: 'owner', isActive: true }, orderBy: { createdAt: 'asc' }, take: 1, select: { userId: true, user: { select: { fullName: true, billingAccount: true } }, }, }, conversationSettings: { select: { organizationId: true } }, }, }); return rows.map((row) => { const owner = row.memberships[0]; const account = owner?.user.billingAccount; return { ...this.orgToDto(row), subscription: account ? toSummary(account) : null, ownerUserId: owner?.userId ?? null, ownerName: owner?.user.fullName ?? null, chatConfigured: Boolean(row.conversationSettings), }; }); } async getOrganization(id: string): Promise { const row = await this.prisma.organization.findUnique({ where: { id }, select: { id: true, name: true, slug: true, businessField: true, isActive: true, createdAt: true, enabledModules: true, _count: { select: { memberships: true, people: true, deals: true, }, }, memberships: { include: { user: { select: { id: true, fullName: true, email: true, mobile: true, isActive: true, billingAccount: true, }, }, }, orderBy: { createdAt: 'asc' }, }, }, }); if (!row) throw new NotFoundException('محیط پیدا نشد'); const ownerAccount = row.memberships.find( (m) => m.role === 'owner' && m.isActive, )?.user.billingAccount; return { ...this.orgToDto(row), subscription: ownerAccount ? toSummary(ownerAccount) : null, members: row.memberships.map((m) => { const isBillingOwner = m.role === 'owner'; return { membershipId: m.id, userId: m.user.id, fullName: m.user.fullName, email: m.user.email, mobile: m.user.mobile, role: m.role, isActive: m.isActive, userIsActive: m.user.isActive, isBillingOwner, billing: isBillingOwner && m.user.billingAccount ? this.billing.toDto(m.user.billingAccount) : null, }; }), }; } async createOrganization( dto: CreatePlatformOrganizationDto, ): Promise { const name = dto.name.trim(); if (name.length < 2) { throw new BadRequestException('نام محیط معتبر نیست'); } const ownerFullName = dto.ownerFullName.trim(); if (ownerFullName.length < 2) { throw new BadRequestException('نام مالک معتبر نیست'); } const ownerEmail = dto.ownerEmail.trim().toLowerCase(); const ownerPassword = dto.ownerPassword.trim(); if (ownerPassword.length < 8) { throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است'); } const businessField = dto.businessField && isBusinessField(dto.businessField) ? dto.businessField : 'personal'; let ownerMobile: string | null = null; if (dto.ownerMobile?.trim()) { ownerMobile = normalizeMobile(dto.ownerMobile); if (!ownerMobile) { throw new BadRequestException('شماره موبایل مالک معتبر نیست'); } } const existingEmail = await this.prisma.user.findFirst({ where: { email: ownerEmail }, select: { id: true }, }); if (existingEmail) { throw new ConflictException('این ایمیل قبلاً ثبت شده است'); } if (ownerMobile) { const existingMobile = await this.prisma.user.findFirst({ where: { mobile: ownerMobile }, select: { id: true }, }); if (existingMobile) { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } } const baseSlug = slugify(name); let slug = baseSlug; let i = 1; while (await this.prisma.organization.findUnique({ where: { slug } })) { slug = `${baseSlug}-${i++}`; } const passwordHash = await bcrypt.hash(ownerPassword, 10); const now = new Date(); try { const organization = await this.prisma.organization.create({ data: { name, slug, businessField, enabledModules: this.billing.defaultModulesForNewOrg(), users: { create: { email: ownerEmail, mobile: ownerMobile, fullName: ownerFullName, passwordHash, role: 'owner', emailVerifiedAt: now, mobileVerifiedAt: ownerMobile ? now : null, }, }, }, 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.orgRoles.ensureDefaultRoles(organization.id); await this.pipelines.ensureDefaultPipeline(organization.id); await this.adminAlerts.notifyNewAccount({ source: 'admin', organizationName: organization.name, ownerName: owner.fullName, mobile: owner.mobile, }); return this.getOrganization(organization.id); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('ایمیل یا موبایل تکراری است'); } throw e; } } async updateOrganization( id: string, dto: UpdatePlatformOrganizationDto, ): Promise { const existing = await this.prisma.organization.findUnique({ where: { id }, select: { id: true, slug: true }, }); if (!existing) throw new NotFoundException('محیط پیدا نشد'); const data: Prisma.OrganizationUpdateInput = {}; if (dto.name !== undefined) { const name = dto.name.trim(); if (name.length < 2) { throw new BadRequestException('نام محیط معتبر نیست'); } data.name = name; } if (dto.slug !== undefined) { const slug = slugify(dto.slug); if (slug.length < 2) { throw new BadRequestException('اسلاگ معتبر نیست'); } if (slug !== existing.slug) { const clash = await this.prisma.organization.findUnique({ where: { slug }, select: { id: true }, }); if (clash) { throw new ConflictException('این اسلاگ قبلاً استفاده شده است'); } data.slug = slug; } } if (dto.businessField !== undefined) { if (dto.businessField === null || dto.businessField === '') { data.businessField = null; } else if (!isBusinessField(dto.businessField)) { throw new BadRequestException('زمینه فعالیت معتبر نیست'); } else { data.businessField = dto.businessField; } } if (dto.isActive !== undefined) { data.isActive = dto.isActive; } if (Object.keys(data).length === 0) { throw new BadRequestException('هیچ فیلدی برای به‌روزرسانی ارسال نشده'); } await this.prisma.organization.update({ where: { id }, data }); return this.getOrganization(id); } async setOrganizationActive( id: string, isActive: boolean, ): Promise { return this.updateOrganization(id, { isActive }); } async listUsers(query?: string, ownersOnly = false): Promise { const search = buildSearchWhere(query, (t) => [ { fullName: ci(t) }, { email: ci(t) }, { mobile: { contains: t } }, ]); const rows = await this.prisma.user.findMany({ where: { ...(search ? { AND: [search] } : {}), ...(ownersOnly ? { memberships: { some: { role: 'owner', isActive: true } } } : {}), }, orderBy: { createdAt: 'desc' }, take: 300, include: { billingAccount: true, memberships: { include: { organization: { select: { id: true, name: true, isActive: true }, }, }, orderBy: { createdAt: 'asc' }, }, }, }); return rows.map((row) => this.userToDto(row)); } async getUser(id: string): Promise { const row = await this.prisma.user.findUnique({ where: { id }, include: { memberships: { include: { organization: { select: { id: true, name: true, isActive: true }, }, }, orderBy: { createdAt: 'asc' }, }, billingAccount: true, }, }); if (!row) throw new NotFoundException('کاربر پیدا نشد'); return this.userToDto(row); } async storageUsage(userId: string, refresh = false) { const owner = await this.prisma.membership.findFirst({ where: { userId, role: 'owner', isActive: true }, select: { id: true }, }); if (!owner) { throw new BadRequestException('حجم فایل فقط برای مالک محیط معنی دارد'); } const account = await this.billing.getByOwnerUserId(userId); if (!account) { throw new BadRequestException('اول برای این مالک یک پلن اعمال کنید'); } return this.storage.usageForOwner(userId, refresh); } async seatUsage(userId: string) { await this.requireBillingOwner(userId); return this.billing.seatUsage(userId); } async setUserCap(userId: string, maxUsers: number) { await this.requireBillingOwner(userId); return this.billing.setMaxUsers(userId, maxUsers); } private async requireBillingOwner(userId: string) { const owner = await this.prisma.membership.findFirst({ where: { userId, role: 'owner', isActive: true }, select: { id: true }, }); if (!owner) { throw new BadRequestException('سقف کاربر فقط برای مالک محیط معنی دارد'); } const account = await this.billing.getByOwnerUserId(userId); if (!account) { throw new BadRequestException('اول برای این مالک یک پلن اعمال کنید'); } } async setStorageQuota(userId: string, quotaGb: number | null) { await this.storageUsage(userId, false); return this.storage.setQuotaGb(userId, quotaGb); } async applyBillingPlan(userId: string, dto: ApplyBillingPlanDto) { const existing = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true }, }); if (!existing) throw new NotFoundException('کاربر پیدا نشد'); await this.billing.applyPlan(userId, dto); return this.getUser(userId); } async applyProductPlan(id: string, planCode: ProductPlanCode) { const existing = await this.prisma.organization.findUnique({ where: { id }, select: { id: true }, }); if (!existing) throw new NotFoundException('محیط پیدا نشد'); await this.billing.applyProductPlan(id, planCode); return this.getOrganization(id); } async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) { const existing = await this.prisma.organization.findUnique({ where: { id }, select: { id: true }, }); if (!existing) throw new NotFoundException('محیط پیدا نشد'); await this.billing.setOrgModules(id, dto.enabledModules ?? []); return this.getOrganization(id); } async setUserActive(id: string, isActive: boolean): Promise { const existing = await this.prisma.user.findUnique({ where: { id }, select: { id: true }, }); if (!existing) throw new NotFoundException('کاربر پیدا نشد'); await this.prisma.user.update({ where: { id }, data: { isActive }, }); return this.getUser(id); } async resetUserPassword( id: string, dto: ResetPlatformUserPasswordDto, ): Promise<{ ok: true }> { const existing = await this.prisma.user.findUnique({ where: { id }, select: { id: true }, }); if (!existing) throw new NotFoundException('کاربر پیدا نشد'); const password = dto.password.trim(); if (password.length < 8) { throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است'); } const passwordHash = await bcrypt.hash(password, 10); await this.prisma.user.update({ where: { id }, data: { passwordHash }, }); return { ok: true }; } async setMembershipActive( id: string, isActive: boolean, ): Promise { const row = await this.prisma.membership.findUnique({ where: { id }, select: { id: true, userId: true, organizationId: true, role: true, isActive: true, }, }); if (!row) throw new NotFoundException('عضویت پیدا نشد'); if (isActive && !row.isActive) { if (row.role === 'owner') { await this.memberships.assertSoleOwner(row.organizationId, row.userId); } await this.billing.assertSeatAvailable(row.organizationId, row.userId); } if (!isActive && row.role === 'owner') { const activeOwners = await this.memberships.countActiveOwners( row.organizationId, row.userId, ); if (activeOwners < 1) { throw new BadRequestException('حداقل یک مالک فعال برای این محیط لازم است'); } } await this.prisma.membership.update({ where: { id }, data: { isActive }, }); return this.getUser(row.userId); } async updateMembershipRole( id: string, role: UserRole, ): Promise { const row = await this.prisma.membership.findUnique({ where: { id }, include: { user: { select: { id: true, organizationId: true } }, }, }); if (!row) throw new NotFoundException('عضویت پیدا نشد'); if (role === 'owner' && row.role !== 'owner') { await this.memberships.assertSoleOwner(row.organizationId, row.userId); } if (row.role === 'owner' && role !== 'owner') { const owners = await this.memberships.countActiveOwners( row.organizationId, row.userId, ); if (owners < 1) { throw new BadRequestException( 'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد', ); } } await this.orgRoles.ensureDefaultRoles(row.organizationId); const orgRole = await this.prisma.orgRole.findFirst({ where: { organizationId: row.organizationId, systemKey: role }, }); await this.prisma.membership.update({ where: { id }, data: { role, orgRoleId: orgRole?.id ?? row.orgRoleId, }, }); if (row.user.organizationId === row.organizationId) { await this.memberships.syncUserLastOrg( row.userId, row.organizationId, role, orgRole?.id ?? row.orgRoleId, ); } return this.getUser(row.userId); } async listAdmins(): Promise { const rows = await this.prisma.platformAdmin.findMany({ orderBy: { createdAt: 'asc' }, }); return rows.map((row) => this.adminToDto(row)); } async createAdmin(dto: CreatePlatformAdminDto): Promise { const fullName = dto.fullName.trim(); if (fullName.length < 2) { throw new BadRequestException('نام معتبر نیست'); } const mobile = normalizeMobile(dto.mobile); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } const password = dto.password.trim(); if (password.length < 8) { throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است'); } const email = dto.email?.trim().toLowerCase() || null; const existingMobile = await this.prisma.platformAdmin.findUnique({ where: { mobile }, select: { id: true }, }); if (existingMobile) { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } if (email) { const existingEmail = await this.prisma.platformAdmin.findUnique({ where: { email }, select: { id: true }, }); if (existingEmail) { throw new ConflictException('این ایمیل قبلاً ثبت شده است'); } } const passwordHash = await bcrypt.hash(password, 10); try { const created = await this.prisma.platformAdmin.create({ data: { fullName, mobile, email, passwordHash, }, }); return this.adminToDto(created); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('موبایل یا ایمیل تکراری است'); } throw e; } } async setAdminActive( id: string, isActive: boolean, actorId: string, ): Promise { const existing = await this.prisma.platformAdmin.findUnique({ where: { id }, select: { id: true }, }); if (!existing) throw new NotFoundException('ادمین پیدا نشد'); if (!isActive && id === actorId) { throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید'); } if (!isActive) { const activeCount = await this.prisma.platformAdmin.count({ where: { isActive: true, id: { not: id } }, }); if (activeCount < 1) { throw new BadRequestException('حداقل یک سوپرادمین فعال لازم است'); } } const updated = await this.prisma.platformAdmin.update({ where: { id }, data: { isActive }, }); return this.adminToDto(updated); } async resetAdminPassword( id: string, dto: ResetPlatformAdminPasswordDto, ): Promise<{ ok: true }> { const existing = await this.prisma.platformAdmin.findUnique({ where: { id }, select: { id: true }, }); if (!existing) throw new NotFoundException('ادمین پیدا نشد'); const password = dto.password.trim(); if (password.length < 8) { throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است'); } const passwordHash = await bcrypt.hash(password, 10); await this.prisma.platformAdmin.update({ where: { id }, data: { passwordHash }, }); return { ok: true }; } async updateAdmin( id: string, dto: { fullName: string; mobile: string; email?: string | null }, ): Promise { const existing = await this.prisma.platformAdmin.findUnique({ where: { id }, select: { id: true }, }); if (!existing) throw new NotFoundException('ادمین پیدا نشد'); const fullName = dto.fullName.trim(); if (fullName.length < 2) { throw new BadRequestException('نام معتبر نیست'); } const mobile = normalizeMobile(dto.mobile); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } const email = dto.email?.trim().toLowerCase() || null; const mobileTaken = await this.prisma.platformAdmin.findFirst({ where: { mobile, id: { not: id } }, select: { id: true }, }); if (mobileTaken) { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } if (email) { const emailTaken = await this.prisma.platformAdmin.findFirst({ where: { email, id: { not: id } }, select: { id: true }, }); if (emailTaken) { throw new ConflictException('این ایمیل قبلاً ثبت شده است'); } } try { const updated = await this.prisma.platformAdmin.update({ where: { id }, data: { fullName, mobile, email }, }); return this.adminToDto(updated); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('موبایل یا ایمیل تکراری است'); } throw e; } } async deleteAdmin(id: string, actorId: string): Promise<{ ok: true }> { const existing = await this.prisma.platformAdmin.findUnique({ where: { id }, select: { id: true }, }); if (!existing) throw new NotFoundException('ادمین پیدا نشد'); if (id === actorId) { throw new BadRequestException('نمی‌توانید حساب خودتان را حذف کنید'); } const others = await this.prisma.platformAdmin.count({ where: { id: { not: id } }, }); if (others < 1) { throw new BadRequestException('حداقل یک سوپرادمین باید بماند'); } await this.prisma.platformAdmin.delete({ where: { id } }); return { ok: true }; } async masterPasswordStatus(): Promise<{ enabled: boolean; updatedAt: string | null }> { const row = await this.prisma.platformMasterPassword.findUnique({ where: { id: 'default' }, select: { updatedAt: true }, }); return { enabled: Boolean(row), updatedAt: row?.updatedAt.toISOString() ?? null, }; } async setMasterPassword( password: string | null, ): Promise<{ enabled: boolean; updatedAt: string | null }> { const trimmed = password?.trim() ?? ''; if (!trimmed) { await this.prisma.platformMasterPassword.deleteMany({ where: { id: 'default' } }); return { enabled: false, updatedAt: null }; } if (trimmed.length < 8) { throw new BadRequestException('رمز کلی حداقل ۸ کاراکتر لازم است'); } const passwordHash = await bcrypt.hash(trimmed, 10); const row = await this.prisma.platformMasterPassword.upsert({ where: { id: 'default' }, create: { id: 'default', passwordHash }, update: { passwordHash }, }); return { enabled: true, updatedAt: row.updatedAt.toISOString() }; } private adminToDto(row: { id: string; mobile: string | null; email: string | null; fullName: string; isActive: boolean; lastLoginAt: Date | null; createdAt: Date; }): PlatformAdminDto { return { id: row.id, mobile: row.mobile, email: row.email, fullName: row.fullName, isActive: row.isActive, lastLoginAt: row.lastLoginAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), }; } private orgToDto(row: { id: string; name: string; slug: string; businessField: string | null; isActive: boolean; createdAt: Date; enabledModules?: string[]; _count: { memberships: number; people: number; deals: number }; }): PlatformOrganizationDto { return { id: row.id, name: row.name, slug: row.slug, businessField: row.businessField, isActive: row.isActive, createdAt: row.createdAt.toISOString(), memberCount: row._count.memberships, personCount: row._count.people, dealCount: row._count.deals, enabledModules: this.billing.normalizeModules(row.enabledModules), }; } private userToDto(row: { id: string; email: string; mobile: string | null; fullName: string; isActive: boolean; lastLoginAt: Date | null; createdAt: Date; billingAccount?: { id: string; ownerUserId: string; planCode: string; status: string; startsAt: Date; expiresAt: Date; maxOrganizations: number; externalPaymentRef: string | null; notes: string | null; } | null; memberships: Array<{ id: string; role: PlatformUserDto['memberships'][number]['role']; isActive: boolean; organization: { id: string; name: string; isActive: boolean }; }>; }): PlatformUserDto { const isBillingOwner = row.memberships.some( (m) => m.role === 'owner' && m.isActive, ); return { id: row.id, email: row.email, mobile: row.mobile, fullName: row.fullName, isActive: row.isActive, lastLoginAt: row.lastLoginAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), isBillingOwner, billing: isBillingOwner && row.billingAccount ? this.billing.toDto(row.billingAccount) : null, memberships: row.memberships.map((m) => ({ membershipId: m.id, organizationId: m.organization.id, organizationName: m.organization.name, organizationIsActive: m.organization.isActive, role: m.role, isActive: m.isActive, })), }; } }