feat(settings): store Khanegi keys in settings tables instead of tenant JSON

Template defaults seed from deploy_settings_const; new tenants copy them, import decides per key, and /get_settings reads the per-tenant rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 20:14:17 +03:30
co-authored by Cursor
parent 8f9988eb15
commit d57ce0a3cb
15 changed files with 2659 additions and 354 deletions
+183 -72
View File
@@ -7,6 +7,7 @@ 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 { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';
@@ -16,6 +17,7 @@ export class TenantsService {
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly notify: AdminNotifyService,
private readonly programSettings: ProgramSettingsService,
) {}
async findAll(product?: ProductType) {
@@ -29,6 +31,7 @@ export class TenantsService {
nameFa: true,
nameEn: true,
domain: true,
domains: true,
avatarUrl: true,
primaryColor: true,
secondaryColor: true,
@@ -38,20 +41,20 @@ export class TenantsService {
appUrl: true,
adminUrl: true,
appUiConfig: true,
setting: true,
uiVariant: true,
appVersion: true,
isActive: true,
notes: true,
createdAt: true,
updatedAt: true,
_count: { select: { settings: true } },
},
});
return rows.map(({ appUiConfig, setting, ...rest }) => ({
return rows.map(({ appUiConfig, _count, ...rest }: any) => ({
...rest,
hasAppUiConfig: Boolean(appUiConfig),
hasProgramSettings: Boolean(setting),
hasProgramSettings: (_count?.settings ?? 0) > 0,
}));
}
@@ -64,36 +67,50 @@ export class TenantsService {
}
async create(dto: CreateTenantDto, userId?: string) {
const aliases = this.uniqueNormalizedHosts(dto.domains);
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,
},
const tenant = await this.prisma.$transaction(async (tx: any) => {
const created = await tx.tenant.create({
data: {
product: dto.product,
slug: dto.slug.toLowerCase(),
nameFa: dto.nameFa,
nameEn: dto.nameEn,
domain: this.normalizeDomain(dto.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',
@@ -120,6 +137,13 @@ export class TenantsService {
throw new NotFoundException('نسخه یافت نشد');
}
const nextAliases =
dto.domains !== undefined ? this.uniqueNormalizedHosts(dto.domains) : undefined;
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) ||
@@ -130,7 +154,8 @@ export class TenantsService {
dto.appUiConfig !== undefined ||
(dto.uiVariant !== undefined && dto.uiVariant !== before.uiVariant) ||
(dto.nameFa !== undefined && dto.nameFa !== before.nameFa) ||
(dto.nameEn !== undefined && dto.nameEn !== before.nameEn);
(dto.nameEn !== undefined && dto.nameEn !== before.nameEn) ||
aliasesChanged;
const explicitVersion =
dto.appVersion !== undefined ? dto.appVersion.trim() || '1' : undefined;
@@ -139,43 +164,56 @@ export class TenantsService {
(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 }),
},
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({
@@ -198,6 +236,9 @@ export class TenantsService {
if (before.nameFa !== tenant.nameFa || before.domain !== tenant.domain) {
bits.push('هویت/دامنه تغییر کرد');
}
if (aliasesChanged) {
bits.push('دامنه‌های اضافی به‌روز شد');
}
this.notify.notifyAdmins(
bits.length
? `سوپرادمین: نسخه «${tenant.nameFa}» — ${bits.join('؛ ')}`
@@ -236,20 +277,90 @@ export class TenantsService {
}
private sanitize(tenant: Tenant) {
const { remoteApiToken, setting, ...rest } = tenant;
const { remoteApiToken, ...rest } = tenant;
return {
...rest,
domains: this.uniqueNormalizedHosts(this.readDomains(tenant.domains)),
hasRemoteApiToken: Boolean(remoteApiToken),
hasProgramSettings: Boolean(setting),
};
}
private normalizeDomain(domain: string) {
return domain
return this.normalizeHost(domain);
}
/** هم‌تراز get_user: پروتکل، مسیر و پورت حذف می‌شود. */
private normalizeHost(value: string) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/\/$/, '');
.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<string>();
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 باطل شود */