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
@@ -0,0 +1,17 @@
import { Controller, Get, Headers, Header, Query } from '@nestjs/common';
import { ProgramSettingsService } from './program-settings.service';
@Controller('public/program-settings')
export class ApiPublicProgramSettingsController {
constructor(private readonly programSettings: ProgramSettingsService) {}
@Get()
@Header('Cache-Control', 'public, max-age=30')
getSettings(
@Headers('host') host?: string,
@Headers('x-forwarded-host') forwardedHost?: string,
@Query('domain') domain?: string,
) {
return this.programSettings.getPublicByHost(domain || forwardedHost || host, domain);
}
}
@@ -0,0 +1,7 @@
import { Allow, IsArray } from 'class-validator';
export class UpdateProgramSettingsDto {
@IsArray()
@Allow()
items!: Array<Record<string, unknown>>;
}
@@ -1,9 +1,10 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { PermissionsGuard } from '../auth/permissions.guard';
import { RequirePermissions } from '../auth/permissions.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { ImportProgramSettingsDto } from './dto/import-program-settings.dto';
import { UpdateProgramSettingsDto } from './dto/update-program-settings.dto';
import { ProgramSettingsService } from './program-settings.service';
@Controller('tenants/:tenantId/program-settings')
@@ -17,6 +18,16 @@ export class ProgramSettingsController {
return this.programSettings.get(tenantId);
}
@Patch()
@RequirePermissions('khanegi:write')
update(
@Param('tenantId') tenantId: string,
@Body() body: UpdateProgramSettingsDto,
@CurrentUser() user: { id: string },
) {
return this.programSettings.updateItems(tenantId, body.items || [], user.id);
}
@Post('import')
@RequirePermissions('khanegi:write')
import(
@@ -1,11 +1,20 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SmsModule } from '../sms/sms.module';
import { ProgramSettingsController } from './program-settings.controller';
import { PublicProgramSettingsController } from './public-program-settings.controller';
import { ApiPublicProgramSettingsController } from './api-public-program-settings.controller';
import { RemoteProgramSettingsController } from './remote-program-settings.controller';
import { ProgramSettingsService } from './program-settings.service';
@Module({
imports: [AuthModule],
controllers: [ProgramSettingsController],
imports: [AuthModule, SmsModule],
controllers: [
ProgramSettingsController,
PublicProgramSettingsController,
ApiPublicProgramSettingsController,
RemoteProgramSettingsController,
],
providers: [ProgramSettingsService],
exports: [ProgramSettingsService],
})
@@ -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') {
@@ -0,0 +1,17 @@
import { Controller, Get, Headers, Header, Query } from '@nestjs/common';
import { ProgramSettingsService } from './program-settings.service';
@Controller()
export class PublicProgramSettingsController {
constructor(private readonly programSettings: ProgramSettingsService) {}
@Get('get_settings')
@Header('Cache-Control', 'public, max-age=30')
getSettings(
@Headers('host') host?: string,
@Headers('x-forwarded-host') forwardedHost?: string,
@Query('domain') domain?: string,
) {
return this.programSettings.getPublicByHost(domain || forwardedHost || host, domain);
}
}
@@ -0,0 +1,15 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard';
import { ProgramSettingsService } from './program-settings.service';
@Controller('remote/program-settings')
@UseGuards(RemoteSmsAuthGuard)
export class RemoteProgramSettingsController {
constructor(private readonly programSettings: ProgramSettingsService) {}
@Get()
getFull(@Req() req: { remoteTenant?: { id: string } }) {
const tenantId = String(req.remoteTenant?.id || '');
return this.programSettings.getRemoteFull(tenantId);
}
}
@@ -0,0 +1,20 @@
/** کلیدهای runtime / لاگ — مال سوپرادمین نیستند؛ در اپ خانگی توسط cron پر می‌شوند. */
export const RUNTIME_PROGRAM_SETTING_KEYS = new Set([
'montzar_last_product_log',
'montzar_order_response',
'montzar_live_price',
'montzar_mazaneh',
'montzar_diff',
'montzar_truly_buy_mazaneh',
'montzar_truly_sell_mazaneh',
'montzar_token',
'montzar_closed',
'up_price',
'down_price',
'price_extreme_date',
'do_send_liman',
]);
export function isRuntimeProgramSettingKey(keyRaw: string): boolean {
return RUNTIME_PROGRAM_SETTING_KEYS.has(String(keyRaw || '').trim());
}
@@ -0,0 +1,41 @@
/** هم‌تراز `Setting::isSecretOrServerOnlyKey` در سرور خانگی — برای پاسخ عمومی `/get_settings`. */
export function isSecretOrServerOnlySettingKey(keyRaw: string): boolean {
const key = String(keyRaw || '')
.trim()
.toLowerCase();
if (!key) return false;
if (
key.startsWith('external_payment_') ||
key === 'wallet_payment_provider' ||
key.startsWith('payping_')
) {
return true;
}
const exact = new Set([
'node_client_id',
'node_shared_secret',
'montzar_password',
'montzar_token',
'montzar_mobile',
'bale_bot_token',
'telegram_bot_token',
'rubika_bot_token',
'tahesab_token',
'tahesab_host',
'tahesab_port',
'tahesab_db',
'pargar_password',
'pargar_api_token',
'pargar_username',
'zarin_api',
'zarin_token',
'chat_webhook_secret',
'chat_webhook_base_url',
'chat_api_key',
]);
if (exact.has(key)) return true;
return /(^|_)(secret|password|token|api_key|apikey|private)(_|$)/.test(key);
}