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:
2026-08-17 17:07:46 +03:30
co-authored by Cursor
parent 25c0a4e941
commit 8f9988eb15
11 changed files with 320 additions and 11 deletions
@@ -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') {