Files
api/src/tenants/tenants.service.ts
T
mohmdsdgandCursor f2cf8c9035 feat(tenants): store Khanegi deploy settings snapshot on tenants.setting
Import can_edit=0 settings via the sold version's admin login + get_settings, for Super Admin program-settings UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:02:22 +03:30

277 lines
9.0 KiB
TypeScript

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 تکراری است');
}
}
}