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:
@@ -12,10 +12,14 @@ async function bootstrap() {
|
||||
ensureUploadsDirs();
|
||||
app.useStaticAssets(UPLOADS_ROOT, { prefix: '/uploads/' });
|
||||
|
||||
app.useBodyParser('json', { limit: '2mb' });
|
||||
app.useBodyParser('urlencoded', { limit: '2mb', extended: true });
|
||||
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [
|
||||
{ path: 'get_user', method: RequestMethod.GET },
|
||||
{ path: 'get_version', method: RequestMethod.GET },
|
||||
{ path: 'get_settings', method: RequestMethod.GET },
|
||||
{ path: 'manifest.json', method: RequestMethod.GET },
|
||||
{ path: 'uploads/(.*)', method: RequestMethod.GET },
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -10,6 +10,6 @@ import { SmsService } from './sms.service';
|
||||
imports: [NotificationsModule, AuthModule],
|
||||
controllers: [SmsController, RemoteSmsController],
|
||||
providers: [SmsService, RemoteSmsAuthGuard],
|
||||
exports: [SmsService],
|
||||
exports: [SmsService, RemoteSmsAuthGuard],
|
||||
})
|
||||
export class SmsModule {}
|
||||
|
||||
Reference in New Issue
Block a user