import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import type { PlatformAdminDto, PlatformOrganizationDto, PlatformStatsDto, PlatformUserDto, 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 } from '../billing/billing.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, ) {} 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, }, }, }, }); return rows.map((row) => this.orgToDto(row)); } 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('محیط پیدا نشد'); return { ...this.orgToDto(row), 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); 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 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 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, }, }); if (!row) throw new NotFoundException('عضویت پیدا نشد'); 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 (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 }; } 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, })), }; } }