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 { ProgramSettingsService } from '../program-settings/program-settings.service'; import { ReplicaWatchService } from '../replica-watch/replica-watch.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, private readonly programSettings: ProgramSettingsService, private readonly replicaWatch: ReplicaWatchService, ) {} 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, domains: true, avatarUrl: true, primaryColor: true, secondaryColor: true, backgroundColor: true, nodeServer: true, nodeWs: true, appUrl: true, adminUrl: true, appUiConfig: true, uiVariant: true, appVersion: true, isActive: true, notes: true, createdAt: true, updatedAt: true, _count: { select: { settings: true } }, }, }); return rows.map(({ appUiConfig, _count, ...rest }: any) => ({ ...rest, hasAppUiConfig: Boolean(appUiConfig), hasProgramSettings: (_count?.settings ?? 0) > 0, })); } 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) { const slug = dto.slug.toLowerCase().trim(); const domain = this.normalizeDomain(dto.domain); const aliases = this.uniqueNormalizedHosts(dto.domains).filter( (host) => this.hostKey(host) !== this.hostKey(domain), ); await this.assertSlugDomainAvailable({ slug, domain }); try { const tenant = await this.prisma.$transaction(async (tx: any) => { const created = await tx.tenant.create({ data: { product: dto.product, slug, nameFa: dto.nameFa, nameEn: dto.nameEn, domain, domains: aliases as unknown as Prisma.InputJsonValue, 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.reclaimAliasesFromSameProduct(tx, { tenantId: created.id, product: created.product, aliases, }); return created; }); if (tenant.product === ProductType.khanegi) { await this.programSettings.seedTenantFromTemplates(tenant.id); } 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}) ایجاد شد.`, ); this.replicaWatch.kick(); 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 nextDomain = dto.domain !== undefined ? this.normalizeDomain(dto.domain) : before.domain; const nextAliasesRaw = dto.domains !== undefined ? this.uniqueNormalizedHosts(dto.domains) : undefined; const nextAliases = nextAliasesRaw !== undefined ? nextAliasesRaw.filter((host) => this.hostKey(host) !== this.hostKey(nextDomain)) : undefined; if (dto.slug !== undefined || dto.domain !== undefined) { await this.assertSlugDomainAvailable({ slug: dto.slug !== undefined ? dto.slug.toLowerCase().trim() : undefined, domain: dto.domain !== undefined ? nextDomain : undefined, excludeId: id, }); } const aliasesChanged = nextAliases !== undefined && JSON.stringify(this.uniqueNormalizedHosts(this.readDomains(before.domains))) !== JSON.stringify(nextAliases); 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) || aliasesChanged; 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.$transaction(async (tx: any) => { if (nextAliases !== undefined) { await this.reclaimAliasesFromSameProduct(tx, { tenantId: id, product: dto.product ?? before.product, aliases: nextAliases, }); } return tx.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), }), ...(nextAliases !== undefined && { domains: nextAliases as unknown as Prisma.InputJsonValue, }), ...(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('هویت/دامنه تغییر کرد'); } if (aliasesChanged) { bits.push('دامنه‌های اضافی به‌روز شد'); } this.notify.notifyAdmins( bits.length ? `سوپرادمین: نسخه «${tenant.nameFa}» — ${bits.join('؛ ')}` : `سوپرادمین: نسخه «${tenant.nameFa}» به‌روزرسانی شد.`, ); this.replicaWatch.kick(); 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}) حذف شد.`, ); this.replicaWatch.kick(); return { ok: true }; } private sanitize(tenant: Tenant) { const { remoteApiToken, ...rest } = tenant; return { ...rest, domains: this.uniqueNormalizedHosts(this.readDomains(tenant.domains)), hasRemoteApiToken: Boolean(remoteApiToken), }; } private normalizeDomain(domain: string) { return this.normalizeHost(domain); } /** هم‌تراز get_user: پروتکل، مسیر و پورت حذف می‌شود. */ private normalizeHost(value: string) { return String(value || '') .trim() .toLowerCase() .replace(/^https?:\/\//, '') .split('/')[0] .split(':')[0]; } private hostKey(value: string) { return this.normalizeHost(value).replace(/^www\./, ''); } private readDomains(raw: unknown): string[] { if (!raw) return []; if (Array.isArray(raw)) return raw.map(String); if (typeof raw === 'string') { try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(String) : []; } catch { return []; } } return []; } private uniqueNormalizedHosts(list: unknown): string[] { const seen = new Set(); const out: string[] = []; for (const item of Array.isArray(list) ? list : []) { const host = this.normalizeHost(String(item || '')); if (!host) continue; const key = this.hostKey(host); if (seen.has(key)) continue; seen.add(key); out.push(host); } return out; } /** * دامنه/IP در aliases این نسخه، از domains[] نسخه‌های دیگر همان محصول برداشته می‌شود. * خانگی و همکار جدا هستند؛ دامنهٔ اصلی (ستون domain) جابه‌جا نمی‌شود. */ private async reclaimAliasesFromSameProduct( tx: any, opts: { tenantId: string; product: ProductType; aliases: string[] }, ) { const keys = new Set(opts.aliases.map((item) => this.hostKey(item)).filter(Boolean)); if (!keys.size) return; const others = await tx.tenant.findMany({ where: { product: opts.product, id: { not: opts.tenantId } }, select: { id: true, nameFa: true, domain: true, domains: true }, }); for (const other of others) { if (keys.has(this.hostKey(other.domain))) { throw new ConflictException( `«${other.domain}» دامنهٔ اصلی نسخه «${other.nameFa}» است و نمی‌توان آن را به این پروژه منتقل کرد`, ); } const current = this.readDomains(other.domains); const next = current.filter((item) => !keys.has(this.hostKey(item))); if (next.length === current.length) continue; await tx.tenant.update({ where: { id: other.id }, data: { domains: next as unknown as Prisma.InputJsonValue }, }); } } /** با تغییر برندینگ، 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 async assertSlugDomainAvailable(opts: { slug?: string; domain?: string; excludeId?: string; }) { if (opts.slug) { const bySlug = await this.prisma.tenant.findUnique({ where: { slug: opts.slug }, select: { id: true, nameFa: true, domain: true, slug: true }, }); if (bySlug && bySlug.id !== opts.excludeId) { throw new ConflictException( `slug «${opts.slug}» قبلاً برای نسخه «${bySlug.nameFa}» (${bySlug.domain}) ثبت شده است`, ); } } if (opts.domain) { const byDomain = await this.prisma.tenant.findUnique({ where: { domain: opts.domain }, select: { id: true, nameFa: true, domain: true, slug: true }, }); if (byDomain && byDomain.id !== opts.excludeId) { throw new ConflictException( `دامنه «${opts.domain}» قبلاً دامنهٔ اصلی نسخه «${byDomain.nameFa}» (slug: ${byDomain.slug}) است`, ); } } } private handleUnique(error: unknown) { if (!(error instanceof Prisma.PrismaClientKnownRequestError)) { return; } const prismaError = error; if (prismaError.code !== 'P2002') { return; } const targets = Array.isArray(prismaError.meta?.target) ? (prismaError.meta.target as string[]) : []; if (targets.includes('slug')) { throw new ConflictException('slug تکراری است — این slug قبلاً برای نسخه دیگری ثبت شده'); } if (targets.includes('domain')) { throw new ConflictException( 'دامنه تکراری است — این دامنه قبلاً دامنهٔ اصلی نسخه دیگری است', ); } throw new ConflictException('slug یا domain تکراری است'); } }