import { ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma, ProductType, Tenant } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AdminNotifyService } from '../notifications/admin-notify.service'; import { CreateTenantDto } from './dto/create-tenant.dto'; import { UpdateTenantDto } from './dto/update-tenant.dto'; @Injectable() export class TenantsService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly notify: AdminNotifyService, ) {} async findAll(product?: ProductType) { const rows = await this.prisma.tenant.findMany({ where: product ? { product } : undefined, orderBy: { nameFa: 'asc' }, select: { id: true, product: true, slug: true, nameFa: true, nameEn: true, domain: true, avatarUrl: true, primaryColor: true, secondaryColor: true, backgroundColor: true, nodeServer: true, nodeWs: true, appUrl: true, adminUrl: true, appUiConfig: true, setting: true, uiVariant: true, appVersion: true, isActive: true, notes: true, createdAt: true, updatedAt: true, }, }); return rows.map(({ appUiConfig, setting, ...rest }) => ({ ...rest, hasAppUiConfig: Boolean(appUiConfig), hasProgramSettings: Boolean(setting), })); } async findOne(id: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id } }); if (!tenant) { throw new NotFoundException('نسخه یافت نشد'); } return this.sanitize(tenant); } async create(dto: CreateTenantDto, userId?: string) { try { const tenant = await this.prisma.tenant.create({ data: { product: dto.product, slug: dto.slug.toLowerCase(), nameFa: dto.nameFa, nameEn: dto.nameEn, domain: this.normalizeDomain(dto.domain), avatarUrl: dto.avatarUrl, primaryColor: dto.primaryColor, secondaryColor: dto.secondaryColor, backgroundColor: dto.backgroundColor, nodeServer: dto.nodeServer, nodeWs: dto.nodeWs, appUrl: dto.appUrl, adminUrl: dto.adminUrl, remoteApiToken: dto.remoteApiToken, appUiConfig: dto.appUiConfig ? (dto.appUiConfig as Prisma.InputJsonValue) : undefined, uiVariant: dto.product === ProductType.khanegi ? dto.uiVariant ?? 'modular' : 'modular', appVersion: dto.appVersion?.trim() || '1', isActive: dto.isActive ?? true, notes: dto.notes, }, }); await this.audit.log({ action: 'tenant.create', entityType: 'tenant', entityId: tenant.id, tenantId: tenant.id, userId, after: this.sanitize(tenant) as unknown as Prisma.InputJsonValue, }); this.notify.notifyAdmins( `سوپرادمین: نسخه جدید «${tenant.nameFa}» (${tenant.domain}) ایجاد شد.`, ); return this.sanitize(tenant); } catch (error) { this.handleUnique(error); throw error; } } async update(id: string, dto: UpdateTenantDto, userId?: string) { const before = await this.prisma.tenant.findUnique({ where: { id } }); if (!before) { throw new NotFoundException('نسخه یافت نشد'); } const brandingChanged = (dto.avatarUrl !== undefined && dto.avatarUrl !== before.avatarUrl) || (dto.primaryColor !== undefined && dto.primaryColor !== before.primaryColor) || (dto.secondaryColor !== undefined && dto.secondaryColor !== before.secondaryColor) || (dto.backgroundColor !== undefined && dto.backgroundColor !== before.backgroundColor) || dto.appUiConfig !== undefined || (dto.uiVariant !== undefined && dto.uiVariant !== before.uiVariant) || (dto.nameFa !== undefined && dto.nameFa !== before.nameFa) || (dto.nameEn !== undefined && dto.nameEn !== before.nameEn); const explicitVersion = dto.appVersion !== undefined ? dto.appVersion.trim() || '1' : undefined; const nextAppVersion = explicitVersion ?? (brandingChanged ? this.bumpAppVersion(before.appVersion) : undefined); try { const tenant = await this.prisma.tenant.update({ where: { id }, data: { ...(dto.product !== undefined && { product: dto.product }), ...(dto.slug !== undefined && { slug: dto.slug.toLowerCase() }), ...(dto.nameFa !== undefined && { nameFa: dto.nameFa }), ...(dto.nameEn !== undefined && { nameEn: dto.nameEn }), ...(dto.domain !== undefined && { domain: this.normalizeDomain(dto.domain), }), ...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }), ...(dto.primaryColor !== undefined && { primaryColor: dto.primaryColor, }), ...(dto.secondaryColor !== undefined && { secondaryColor: dto.secondaryColor, }), ...(dto.backgroundColor !== undefined && { backgroundColor: dto.backgroundColor, }), ...(dto.nodeServer !== undefined && { nodeServer: dto.nodeServer }), ...(dto.nodeWs !== undefined && { nodeWs: dto.nodeWs }), ...(dto.appUrl !== undefined && { appUrl: dto.appUrl }), ...(dto.adminUrl !== undefined && { adminUrl: dto.adminUrl }), ...(dto.remoteApiToken !== undefined && { remoteApiToken: dto.remoteApiToken, }), ...(dto.appUiConfig !== undefined && { appUiConfig: dto.appUiConfig as Prisma.InputJsonValue, }), ...(dto.uiVariant !== undefined && { uiVariant: dto.uiVariant }), ...(nextAppVersion !== undefined && { appVersion: nextAppVersion, }), ...(dto.isActive !== undefined && { isActive: dto.isActive }), ...(dto.notes !== undefined && { notes: dto.notes }), }, }); await this.audit.log({ action: 'tenant.update', entityType: 'tenant', entityId: tenant.id, tenantId: tenant.id, userId, before: this.sanitize(before) as unknown as Prisma.InputJsonValue, after: this.sanitize(tenant) as unknown as Prisma.InputJsonValue, }); const bits: string[] = []; if (before.isActive !== tenant.isActive) { bits.push(tenant.isActive ? 'فعال شد' : 'غیرفعال شد'); } if (before.appVersion !== tenant.appVersion) { bits.push(`ورژن ${before.appVersion} → ${tenant.appVersion}`); } if (before.nameFa !== tenant.nameFa || before.domain !== tenant.domain) { bits.push('هویت/دامنه تغییر کرد'); } this.notify.notifyAdmins( bits.length ? `سوپرادمین: نسخه «${tenant.nameFa}» — ${bits.join('؛ ')}` : `سوپرادمین: نسخه «${tenant.nameFa}» به‌روزرسانی شد.`, ); return this.sanitize(tenant); } catch (error) { this.handleUnique(error); throw error; } } async remove(id: string, userId?: string) { const before = await this.prisma.tenant.findUnique({ where: { id } }); if (!before) { throw new NotFoundException('نسخه یافت نشد'); } await this.prisma.tenant.delete({ where: { id } }); await this.audit.log({ action: 'tenant.delete', entityType: 'tenant', entityId: id, tenantId: id, userId, before: this.sanitize(before) as unknown as Prisma.InputJsonValue, }); this.notify.notifyAdmins( `سوپرادمین: نسخه «${before.nameFa}» (${before.domain}) حذف شد.`, ); return { ok: true }; } private sanitize(tenant: Tenant) { const { remoteApiToken, setting, ...rest } = tenant; return { ...rest, hasRemoteApiToken: Boolean(remoteApiToken), hasProgramSettings: Boolean(setting), }; } private normalizeDomain(domain: string) { return domain .trim() .toLowerCase() .replace(/^https?:\/\//, '') .replace(/\/$/, ''); } /** با تغییر برندینگ، get_user کش فرانت با get_version باطل شود */ private bumpAppVersion(current?: string | null): string { const v = String(current || '1').trim() || '1'; if (/^\d+$/.test(v)) { return String(Number(v) + 1); } const semver = v.match(/^(\d+)\.(\d+)\.(\d+)$/); if (semver) { return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`; } return `${Date.now()}`; } private handleUnique(error: unknown) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { throw new ConflictException('slug یا domain تکراری است'); } } }