feat(program-settings): expose tenant settings APIs and merge PATCH by key
Sold apps can read settings from /get_settings; Super Admin saves only changed keys so huge runtime logs are not sent in the body. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,8 @@ import axios from 'axios';
|
||||
import { Prisma, ProductType } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { isSecretOrServerOnlySettingKey } from './secret-setting-keys';
|
||||
import { isRuntimeProgramSettingKey } from './runtime-setting-keys';
|
||||
|
||||
export type ProgramSettingRow = {
|
||||
id?: number;
|
||||
@@ -24,7 +26,7 @@ export type ProgramSettingRow = {
|
||||
|
||||
export type ProgramSettingsPayload = {
|
||||
importedAt: string;
|
||||
source: 'khanegi_admin';
|
||||
source: 'khanegi_admin' | 'superadmin';
|
||||
apiBase: string;
|
||||
items: ProgramSettingRow[];
|
||||
};
|
||||
@@ -44,8 +46,103 @@ export class ProgramSettingsService {
|
||||
importedAt: stored?.importedAt ?? null,
|
||||
source: stored?.source ?? null,
|
||||
apiBase: stored?.apiBase ?? null,
|
||||
items: stored?.items ?? [],
|
||||
count: stored?.items?.length ?? 0,
|
||||
items: this.omitRuntime(stored?.items ?? []),
|
||||
count: this.omitRuntime(stored?.items ?? []).length,
|
||||
};
|
||||
}
|
||||
|
||||
async getPublicByHost(hostHeader?: string, domainQuery?: string) {
|
||||
const tenant = await this.findKhanegiTenantByHost(domainQuery || hostHeader || '');
|
||||
const stored = this.readStored(tenant.setting);
|
||||
const items = this.omitRuntime(stored?.items ?? []).filter(
|
||||
(row) => !isSecretOrServerOnlySettingKey(String(row.key || '')),
|
||||
);
|
||||
return {
|
||||
settings: items,
|
||||
updatedAt: stored?.importedAt || null,
|
||||
count: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getRemoteFull(tenantId: string) {
|
||||
const tenant = await this.requireKhanegiTenant(tenantId);
|
||||
const stored = this.readStored(tenant.setting);
|
||||
return {
|
||||
settings: this.omitRuntime(stored?.items ?? []),
|
||||
updatedAt: stored?.importedAt || null,
|
||||
count: this.omitRuntime(stored?.items ?? []).length,
|
||||
};
|
||||
}
|
||||
|
||||
async updateItems(
|
||||
tenantId: string,
|
||||
itemsRaw: Array<Record<string, unknown>>,
|
||||
userId?: string,
|
||||
) {
|
||||
const tenant = await this.requireKhanegiTenant(tenantId);
|
||||
const incoming = this.asSettingsArray(itemsRaw)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
key: String(row.key || '').trim(),
|
||||
value: row.value == null ? '' : String(row.value),
|
||||
can_edit: 0 as const,
|
||||
is_active:
|
||||
row.is_active === 0 || row.is_active === false || row.is_active === '0' ? 0 : 1,
|
||||
}))
|
||||
.filter((row) => row.key && !isRuntimeProgramSettingKey(row.key));
|
||||
|
||||
if (!incoming.length) {
|
||||
throw new BadRequestException('هیچ تنظیم معتبری برای ذخیره ارسال نشده');
|
||||
}
|
||||
|
||||
const previous = this.readStored(tenant.setting);
|
||||
const byKey = new Map<string, ProgramSettingRow>();
|
||||
for (const row of previous?.items ?? []) {
|
||||
const key = String(row.key || '').trim();
|
||||
if (!key || isRuntimeProgramSettingKey(key)) continue;
|
||||
byKey.set(key, row);
|
||||
}
|
||||
for (const row of incoming) {
|
||||
const prev = byKey.get(row.key) || {};
|
||||
byKey.set(row.key, { ...prev, ...row });
|
||||
}
|
||||
const items = [...byKey.values()];
|
||||
|
||||
const payload: ProgramSettingsPayload = {
|
||||
importedAt: new Date().toISOString(),
|
||||
source: 'superadmin',
|
||||
apiBase: previous?.apiBase || '',
|
||||
items,
|
||||
};
|
||||
|
||||
await this.prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
setting: payload as unknown as Prisma.InputJsonValue,
|
||||
appVersion: this.bumpAppVersion(tenant.appVersion),
|
||||
},
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
action: 'tenant.program_settings.update',
|
||||
entityType: 'tenant_program_settings',
|
||||
entityId: tenantId,
|
||||
tenantId,
|
||||
userId,
|
||||
after: {
|
||||
importedAt: payload.importedAt,
|
||||
count: incoming.length,
|
||||
keys: incoming.map((row) => row.key),
|
||||
} as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
return {
|
||||
tenant: this.tenantMeta(tenant),
|
||||
importedAt: payload.importedAt,
|
||||
source: payload.source,
|
||||
apiBase: payload.apiBase,
|
||||
items: this.omitRuntime(items),
|
||||
count: this.omitRuntime(items).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,7 +172,10 @@ export class ProgramSettingsService {
|
||||
|
||||
await this.prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: { setting: payload as unknown as Prisma.InputJsonValue },
|
||||
data: {
|
||||
setting: payload as unknown as Prisma.InputJsonValue,
|
||||
appVersion: this.bumpAppVersion(tenant.appVersion),
|
||||
},
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
@@ -155,12 +255,72 @@ export class ProgramSettingsService {
|
||||
const items = this.asSettingsArray(obj.items ?? obj);
|
||||
return {
|
||||
importedAt: String(obj.importedAt || ''),
|
||||
source: 'khanegi_admin',
|
||||
source: obj.source === 'superadmin' ? 'superadmin' : 'khanegi_admin',
|
||||
apiBase: String(obj.apiBase || ''),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
private async findKhanegiTenantByHost(hostRaw: string) {
|
||||
const host = this.normalizeHost(hostRaw);
|
||||
if (!host) {
|
||||
throw new NotFoundException('domain required');
|
||||
}
|
||||
|
||||
const tenants = await this.prisma.tenant.findMany({
|
||||
where: { isActive: true, product: ProductType.khanegi },
|
||||
});
|
||||
|
||||
const hostNoWww = host.replace(/^www\./, '');
|
||||
const primary = tenants.find((item) => {
|
||||
const d = this.normalizeHost(item.domain);
|
||||
return d === host || d.replace(/^www\./, '') === hostNoWww;
|
||||
});
|
||||
const tenant =
|
||||
primary ||
|
||||
tenants.find((item) => {
|
||||
for (const value of this.readDomains(item.domains)) {
|
||||
const normalized = this.normalizeHost(value);
|
||||
if (!normalized) continue;
|
||||
if (normalized === host || normalized.replace(/^www\./, '') === hostNoWww) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('tenant not found for domain');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
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 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 loginKhanegiAdmin(
|
||||
tenant: {
|
||||
nodeServer: string;
|
||||
@@ -251,7 +411,9 @@ export class ProgramSettingsService {
|
||||
headers,
|
||||
timeout: 25000,
|
||||
});
|
||||
const items = this.asSettingsArray(data).filter((row) => this.isSystemSetting(row));
|
||||
const items = this.asSettingsArray(data)
|
||||
.filter((row) => this.isSystemSetting(row))
|
||||
.filter((row) => !isRuntimeProgramSettingKey(String(row.key || '')));
|
||||
if (items.length) return items;
|
||||
} catch (error) {
|
||||
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
||||
@@ -264,7 +426,9 @@ export class ProgramSettingsService {
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(url, { headers, timeout: 25000 });
|
||||
return this.asSettingsArray(data).filter((row) => this.isSystemSetting(row));
|
||||
return this.asSettingsArray(data)
|
||||
.filter((row) => this.isSystemSetting(row))
|
||||
.filter((row) => !isRuntimeProgramSettingKey(String(row.key || '')));
|
||||
} catch (error) {
|
||||
throw new BadGatewayException(
|
||||
this.axiosMessage(error, 'خواندن get_settings از اپ خانگی ناموفق بود'),
|
||||
@@ -313,6 +477,10 @@ export class ProgramSettingsService {
|
||||
return String(raw || '').trim();
|
||||
}
|
||||
|
||||
private omitRuntime(items: ProgramSettingRow[]): ProgramSettingRow[] {
|
||||
return items.filter((row) => !isRuntimeProgramSettingKey(String(row.key || '')));
|
||||
}
|
||||
|
||||
private asSettingsArray(data: unknown): ProgramSettingRow[] {
|
||||
if (Array.isArray(data)) return data as ProgramSettingRow[];
|
||||
if (data && typeof data === 'object') {
|
||||
|
||||
Reference in New Issue
Block a user