import { BadGatewayException, BadRequestException, ForbiddenException, HttpException, Injectable, Logger, NotFoundException, OnModuleInit, } from '@nestjs/common'; import axios from 'axios'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { Prisma, ProductType, Setting, SettingTemplate } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { ReplicaWatchService } from '../replica-watch/replica-watch.service'; import { isSecretOrServerOnlySettingKey } from './secret-setting-keys'; import { isRuntimeProgramSettingKey } from './runtime-setting-keys'; import { DEPLOY_SETTING_CATALOG_KEYS, DEPLOY_SETTING_EDITABLE_KEYS, } from './deploy-setting-catalog-keys'; export type ProgramSettingRow = { id?: string | number; key?: string; value?: string | number | boolean | null; title_fa?: string | null; description?: string | null; type?: string | null; type_data?: string | null; is_active?: number | boolean | string | null; can_edit?: number | boolean | string | null; is_exist?: number | boolean | string | null; }; export type ImportDecisionScope = 'none' | 'this' | 'all'; export type ImportDecision = { key: string; scope: ImportDecisionScope; }; type TemplateSeedRow = { key: string; value?: string | null; title_fa?: string | null; can_edit?: number | null; is_active?: number | null; description?: string | null; type?: string | null; type_data?: string | null; is_exist?: boolean | number | null; }; @Injectable() export class ProgramSettingsService implements OnModuleInit { private readonly logger = new Logger(ProgramSettingsService.name); constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly replicaWatch: ReplicaWatchService, ) {} async onModuleInit() { try { const result = await this.ensureTemplatesSeeded(); if (result.seeded) { this.logger.log(`setting_templates seeded (${result.count} keys)`); } const purged = await this.purgeEditableFromSuperAdmin(); if (purged.templates || purged.settings) { this.logger.log( `purged can_edit=1 from superadmin (templates=${purged.templates}, settings=${purged.settings})`, ); } await this.seedEmptyKhanegiTenants(); } catch (error: any) { this.logger.warn(`setting_templates seed skipped: ${error?.message || error}`); } } async get(tenantId: string) { const tenant = await this.requireKhanegiTenant(tenantId); const items = await this.listTenantRows(tenantId); return { tenant: this.tenantMeta(tenant), items, count: items.length, }; } async getPublicByHost(hostHeader?: string, domainQuery?: string) { const tenant = await this.findKhanegiTenantByHost(domainQuery || hostHeader || ''); const items = (await this.listTenantRows(tenant.id)).filter( (row) => !isSecretOrServerOnlySettingKey(String(row.key || '')), ); return { settings: items, updatedAt: null, count: items.length, }; } async getRemoteFull(tenantId: string) { const tenant = await this.requireKhanegiTenant(tenantId); const items = await this.listTenantRows(tenant.id); return { settings: items, updatedAt: null, count: items.length, }; } async updateItems( tenantId: string, itemsRaw: Array>, userId?: string, ) { const tenant = await this.requireKhanegiTenant(tenantId); const incoming = this.asSettingsArray(itemsRaw) .map((row) => this.normalizeIncoming(row)) .filter((row) => row.key && !isRuntimeProgramSettingKey(row.key)) .filter((row) => this.isSystemProgramSettingKey(row.key)); if (!incoming.length) { throw new BadRequestException('هیچ تنظیم معتبری برای ذخیره ارسال نشده'); } await this.prisma.$transaction(async (tx: any) => { for (const row of incoming) { const existing = await tx.setting.findFirst({ where: { tenantId, key: row.key, deletedAt: null }, }); if (existing) { await tx.setting.update({ where: { id: existing.id }, data: { value: row.value, titleFa: row.title_fa ?? existing.titleFa, description: row.description ?? existing.description, type: row.type ?? existing.type, typeData: row.type_data ?? existing.typeData, isActive: this.toSmallInt(row.is_active, existing.isActive ?? 1), canEdit: 0, isExist: true, deletedAt: null, }, }); } else { await tx.setting.create({ data: { tenantId, key: row.key, value: row.value, titleFa: row.title_fa || row.key, description: row.description || '', type: row.type || '', typeData: row.type_data || null, isActive: this.toSmallInt(row.is_active, 1), canEdit: 0, isExist: true, }, }); } } await tx.tenant.update({ where: { id: tenantId }, data: { appVersion: this.bumpAppVersion(tenant.appVersion) }, }); }); await this.audit.log({ action: 'tenant.program_settings.update', entityType: 'tenant_program_settings', entityId: tenantId, tenantId, userId, after: { count: incoming.length, keys: incoming.map((row) => row.key), } as Prisma.InputJsonValue, }); const items = await this.listTenantRows(tenantId); this.replicaWatch.kick(); return { tenant: this.tenantMeta(tenant), items, count: items.length, }; } /** پیش‌نمایش ایمپورت — تصمیم فقط برای کلید خارج از قالب deploy_settings_const */ async previewImport(tenantId: string, mobile: string, password: string) { const tenant = await this.requireKhanegiTenant(tenantId); const mobileNorm = this.normalizeAdminMobile(mobile); if (!/^\d{11}$/.test(mobileNorm)) { throw new BadRequestException('موبایل ادمین خانگی باید ۱۱ رقم باشد (مثلاً 0912…)'); } const { apiBase, token } = await this.loginKhanegiAdmin(tenant, mobileNorm, password); await this.ensureTemplatesSeeded(); const remote = await this.fetchSystemSettings(apiBase, token); const local = await this.listTenantRows(tenantId); const catalogKeys = this.deployCatalogKeySet(); const seedCanEdit = this.deployCatalogCanEditMap(); const localByKey = new Map(local.map((row) => [String(row.key), row])); const remoteByKey = new Map( remote .map((row) => this.normalizeIncoming(row)) .filter((row) => row.key && !isRuntimeProgramSettingKey(row.key)) .map((row) => [row.key, row]), ); const decisionsNeeded: Array<{ key: string; title_fa: string; khanegi_value: string; superadmin_value: string | null; status: 'new_in_khanegi' | 'both'; }> = []; for (const [key, row] of remoteByKey) { if (catalogKeys.has(key)) continue; const localRow = localByKey.get(key); const khanegiValue = String(row.value ?? ''); const superadminValue = localRow ? String(localRow.value ?? '') : null; if (localRow && khanegiValue === superadminValue) continue; decisionsNeeded.push({ key, title_fa: String(row.title_fa || localRow?.title_fa || key), khanegi_value: khanegiValue, superadmin_value: superadminValue, status: localRow ? 'both' : 'new_in_khanegi', }); } const onlySuperAdmin = [...catalogKeys].filter((key) => { if (isRuntimeProgramSettingKey(key)) return false; if (remoteByKey.has(key)) return false; const canEdit = seedCanEdit.get(key); if (canEdit === 1) return false; return true; }); return { tenant: this.tenantMeta(tenant), apiBase, decisionsNeeded, onlySuperAdminKeys: onlySuperAdmin, importableCount: remoteByKey.size, note: 'کلیدهای قالب deploy_settings_const بدون سؤال روی همین پروژه نوشته می‌شوند. فقط کلید خارج از قالب اینجا می‌آید.', }; } async applyImport( tenantId: string, mobile: string, password: string, decisions: ImportDecision[], userId?: string, ) { const tenant = await this.requireKhanegiTenant(tenantId); const mobileNorm = this.normalizeAdminMobile(mobile); if (!/^\d{11}$/.test(mobileNorm)) { throw new BadRequestException('موبایل ادمین خانگی باید ۱۱ رقم باشد (مثلاً 0912…)'); } const { apiBase, token } = await this.loginKhanegiAdmin(tenant, mobileNorm, password); await this.ensureTemplatesSeeded(); const remote = await this.fetchSystemSettings(apiBase, token); const catalogKeys = this.deployCatalogKeySet(); const remoteByKey = new Map( remote .map((row) => this.normalizeIncoming(row)) .filter((row) => row.key && !isRuntimeProgramSettingKey(row.key)) .map((row) => [row.key, row]), ); const decisionMap = new Map(); for (const d of decisions || []) { const key = String(d.key || '').trim(); const scope = d.scope; if (!key) continue; if (scope !== 'none' && scope !== 'this' && scope !== 'all') { throw new BadRequestException(`scope نامعتبر برای ${key}`); } decisionMap.set(key, scope); } const khanegiTenantIds = ( await this.prisma.tenant.findMany({ where: { product: ProductType.khanegi }, select: { id: true }, }) ).map((t: { id: string }) => t.id); await this.prisma.$transaction(async (tx: any) => { // کلیدهایی که فقط در سوپرادمین‌اند → پیش‌فرض قالب برای این پروژه await this.ensureTemplateDefaultsForTenant(tx, tenantId); for (const [key, row] of remoteByKey) { if (!this.isSystemProgramSettingKey(key)) continue; const inCatalog = catalogKeys.has(key); const scope = inCatalog ? 'this' : (decisionMap.get(key) ?? 'this'); if (scope === 'none') continue; const targetIds = scope === 'all' ? khanegiTenantIds : [tenantId]; for (const tid of targetIds) { await this.upsertTenantSetting(tx, tid, { ...row, key, can_edit: 0, }); } if (inCatalog) continue; const tpl = await tx.settingTemplate.findUnique({ where: { key } }); if (!tpl) { await tx.settingTemplate.create({ data: this.templateDataFromRow(row), }); } } await tx.tenant.update({ where: { id: tenantId }, data: { appVersion: this.bumpAppVersion(tenant.appVersion) }, }); }); await this.audit.log({ action: 'tenant.program_settings.import', entityType: 'tenant_program_settings', entityId: tenantId, tenantId, userId, after: { decisions: [...decisionMap.entries()].map(([key, scope]) => ({ key, scope })), apiBase, } as Prisma.InputJsonValue, }); const items = await this.listTenantRows(tenantId); this.replicaWatch.kick(); return { tenant: this.tenantMeta(tenant), apiBase, items, count: items.length, }; } async listTemplates() { await this.ensureTemplatesSeeded(); const rows = await this.prisma.settingTemplate.findMany({ where: { deletedAt: null, key: { in: [...DEPLOY_SETTING_CATALOG_KEYS] }, }, orderBy: { key: 'asc' }, }); return { items: rows.map((row: SettingTemplate) => this.toApiRow(row)), count: rows.length, }; } /** افزودن کلید(ها) به قالب + همهٔ نسخه‌های خانگی */ async addKeys(itemsRaw: Array>, userId?: string) { const incoming = this.asSettingsArray(itemsRaw) .map((row) => this.normalizeIncoming(row)) .filter((row) => row.key && !isRuntimeProgramSettingKey(row.key)) .filter((row) => this.isSystemProgramSettingKey(row.key)); if (!incoming.length) { throw new BadRequestException('هیچ کلید معتبری ارسال نشده'); } await this.ensureTemplatesSeeded(); const tenants = await this.prisma.tenant.findMany({ where: { product: ProductType.khanegi }, select: { id: true }, }); await this.prisma.$transaction(async (tx: any) => { for (const row of incoming) { await tx.settingTemplate.upsert({ where: { key: row.key }, create: this.templateDataFromRow(row), update: { titleFa: row.title_fa || row.key, value: row.value, description: row.description || '', type: row.type || '', typeData: row.type_data || null, canEdit: this.toSmallInt(row.can_edit, 0), isActive: this.toSmallInt(row.is_active, 1), isExist: true, deletedAt: null, }, }); for (const t of tenants) { const existing = await tx.setting.findUnique({ where: { tenantId_key: { tenantId: t.id, key: row.key } }, }); if (existing) continue; await this.upsertTenantSetting(tx, t.id, { ...row, can_edit: 0 }); } } }); await this.audit.log({ action: 'setting_templates.add_keys', entityType: 'setting_template', entityId: 'bulk', userId, after: { keys: incoming.map((r) => r.key), tenants: tenants.length } as Prisma.InputJsonValue, }); this.replicaWatch.kick(); return { keys: incoming.map((r) => r.key), tenantsUpdated: tenants.length, count: incoming.length, }; } /** حذف کلید با اسم از قالب و همهٔ پروژه‌ها */ async deleteKey(keyRaw: string, userId?: string) { const key = String(keyRaw || '').trim(); if (!key) throw new BadRequestException('key لازم است'); const result = await this.prisma.$transaction(async (tx: any) => { const tpl = await tx.settingTemplate.deleteMany({ where: { key } }); const settings = await tx.setting.deleteMany({ where: { key } }); return { templates: tpl.count, settings: settings.count }; }); await this.audit.log({ action: 'setting_templates.delete_key', entityType: 'setting_template', entityId: key, userId, after: result as Prisma.InputJsonValue, }); this.replicaWatch.kick(); return { key, ...result }; } /** کپی قالب روی نسخهٔ جدید خانگی */ async seedTenantFromTemplates(tenantId: string) { await this.ensureTemplatesSeeded(); const templates = await this.prisma.settingTemplate.findMany({ where: { deletedAt: null, key: { in: [...DEPLOY_SETTING_CATALOG_KEYS] }, }, }); if (!templates.length) return { count: 0 }; await this.prisma.$transaction(async (tx: any) => { for (const tpl of templates) { await tx.setting.upsert({ where: { tenantId_key: { tenantId, key: tpl.key } }, create: { tenantId, key: tpl.key, titleFa: tpl.titleFa, value: tpl.value, description: tpl.description, type: tpl.type, typeData: tpl.typeData, canEdit: tpl.canEdit ?? 0, isActive: tpl.isActive ?? 1, isExist: tpl.isExist, }, update: {}, }); } }); return { count: templates.length }; } async ensureTemplatesSeeded() { const raw = this.readSeedRows(); const before = await this.prisma.settingTemplate.count(); if (!raw.length) { return { seeded: false, count: before, missingSeed: true }; } let inserted = 0; await this.prisma.$transaction(async (tx: any) => { for (const row of raw) { const key = String(row.key || '').trim(); if (!key || isRuntimeProgramSettingKey(key)) continue; if (!this.isSystemProgramSettingKey(key)) continue; if (this.toSmallInt(row.can_edit, 0) !== 0) continue; const existing = await tx.settingTemplate.findUnique({ where: { key } }); if (existing) { if (existing.deletedAt) { await tx.settingTemplate.update({ where: { key }, data: { deletedAt: null }, }); } continue; } await tx.settingTemplate.create({ data: { key, titleFa: row.title_fa || key, value: row.value == null ? '' : String(row.value), description: row.description == null ? '' : String(row.description), type: row.type || '', typeData: row.type_data || null, canEdit: this.toSmallInt(row.can_edit, 0), isActive: this.toSmallInt(row.is_active, 1), isExist: row.is_exist === 0 || row.is_exist === false ? false : true, }, }); inserted += 1; } }); const after = await this.prisma.settingTemplate.count(); return { seeded: inserted > 0 || before === 0, count: after }; } private readSeedRows(): TemplateSeedRow[] { const seedPath = this.resolveSeedPath(); if (!seedPath || !existsSync(seedPath)) return []; try { const raw = JSON.parse(readFileSync(seedPath, 'utf8')) as TemplateSeedRow[]; return Array.isArray(raw) ? raw : []; } catch { return []; } } /** فقط can_edit=0 نهایی deploy_settings_const — نه کل INSERTها */ private deployCatalogKeySet() { return new Set(DEPLOY_SETTING_CATALOG_KEYS); } private isSystemProgramSettingKey(key: string) { const k = String(key || '').trim(); if (!k || isRuntimeProgramSettingKey(k)) return false; if (DEPLOY_SETTING_EDITABLE_KEYS.has(k)) return false; return DEPLOY_SETTING_CATALOG_KEYS.has(k); } /** حذف can_edit=1 از قالب و همهٔ پروژه‌های خانگی در DB سوپرادمین */ async purgeEditableFromSuperAdmin() { const keys = [...DEPLOY_SETTING_EDITABLE_KEYS]; if (!keys.length) return { templates: 0, settings: 0 }; const result = await this.prisma.$transaction(async (tx: any) => { const templates = await tx.settingTemplate.deleteMany({ where: { key: { in: keys } }, }); const settings = await tx.setting.deleteMany({ where: { key: { in: keys } }, }); return { templates: templates.count, settings: settings.count }; }); if (result.templates || result.settings) { this.replicaWatch.kick(); } return result; } private deployCatalogCanEditMap() { const map = new Map(); for (const row of this.readSeedRows()) { const key = String(row.key || '').trim(); if (!key) continue; map.set(key, this.toSmallInt(row.can_edit, 0)); } return map; } private async seedEmptyKhanegiTenants() { const tenants = await this.prisma.tenant.findMany({ where: { product: ProductType.khanegi }, select: { id: true, _count: { select: { settings: true } } }, }); for (const t of tenants as Array<{ id: string; _count: { settings: number } }>) { if ((t._count?.settings ?? 0) > 0) continue; await this.seedTenantFromTemplates(t.id); } } private resolveSeedPath() { const candidates = [ join(process.cwd(), 'prisma/setting-templates-from-deploy.json'), join(__dirname, '../../prisma/setting-templates-from-deploy.json'), join(process.cwd(), 'prisma/data/setting-templates-from-deploy.json'), ]; return candidates.find((p) => existsSync(p)) || null; } private async listTenantRows(tenantId: string): Promise { await this.ensureTemplatesSeeded(); const catalogKeys = [...DEPLOY_SETTING_CATALOG_KEYS].sort(); const [tenantRows, templates] = await Promise.all([ this.prisma.setting.findMany({ where: { tenantId, deletedAt: null, key: { in: catalogKeys } }, }), this.prisma.settingTemplate.findMany({ where: { deletedAt: null, key: { in: catalogKeys } }, }), ]); const tenantByKey = new Map(tenantRows.map((row: Setting) => [row.key, row])); const templateByKey = new Map(templates.map((row: SettingTemplate) => [row.key, row])); const seedByKey = new Map( this.readSeedRows() .filter((row) => row.key && DEPLOY_SETTING_CATALOG_KEYS.has(String(row.key))) .map((row) => [String(row.key), row]), ); return catalogKeys .filter((key) => !isRuntimeProgramSettingKey(key)) .map((key) => { const tenant = tenantByKey.get(key); if (tenant) return this.toApiRow(tenant); const tpl = templateByKey.get(key); if (tpl) return this.toApiRow(tpl); const seed = seedByKey.get(key); return { key, value: seed?.value == null ? '' : String(seed.value), title_fa: seed?.title_fa || key, description: seed?.description || '', type: seed?.type || '', type_data: '', is_active: seed?.is_active ?? 1, can_edit: 0, is_exist: 0, }; }); } private toApiRow(row: Setting | SettingTemplate): ProgramSettingRow { return { id: row.id, key: row.key, value: row.value ?? '', title_fa: row.titleFa ?? '', description: row.description ?? '', type: row.type ?? '', type_data: row.typeData ?? '', is_active: row.isActive ?? 1, can_edit: row.canEdit ?? 0, is_exist: row.isExist ? 1 : 0, }; } private normalizeIncoming(row: ProgramSettingRow | Record) { const r = row as Record; return { key: String(r.key || '').trim(), value: r.value == null ? '' : String(r.value), title_fa: r.title_fa == null ? '' : String(r.title_fa), description: r.description == null ? '' : String(r.description), type: r.type == null ? '' : String(r.type), type_data: r.type_data == null ? null : String(r.type_data), is_active: r.is_active, can_edit: r.can_edit, is_exist: r.is_exist, }; } private templateDataFromRow(row: ReturnType) { return { key: row.key, titleFa: row.title_fa || row.key, value: row.value, description: row.description || '', type: row.type || '', typeData: row.type_data || null, canEdit: this.toSmallInt(row.can_edit, 0), isActive: this.toSmallInt(row.is_active, 1), isExist: true, deletedAt: null as Date | null, }; } private async upsertTenantSetting( tx: any, tenantId: string, row: ReturnType, ) { await tx.setting.upsert({ where: { tenantId_key: { tenantId, key: row.key } }, create: { tenantId, key: row.key, titleFa: row.title_fa || row.key, value: row.value, description: row.description || '', type: row.type || '', typeData: row.type_data || null, canEdit: this.toSmallInt(row.can_edit, 0), isActive: this.toSmallInt(row.is_active, 1), isExist: true, }, update: { value: row.value, titleFa: row.title_fa || row.key, description: row.description || '', type: row.type || '', typeData: row.type_data || null, canEdit: this.toSmallInt(row.can_edit, 0), isActive: this.toSmallInt(row.is_active, 1), isExist: true, deletedAt: null, }, }); } private async ensureTemplateDefaultsForTenant( tx: any, tenantId: string, ) { const templates = await tx.settingTemplate.findMany({ where: { deletedAt: null, key: { in: [...DEPLOY_SETTING_CATALOG_KEYS] }, }, }); for (const tpl of templates) { const existing = await tx.setting.findUnique({ where: { tenantId_key: { tenantId, key: tpl.key } }, }); if (existing) continue; await tx.setting.create({ data: { tenantId, key: tpl.key, titleFa: tpl.titleFa, value: tpl.value, description: tpl.description, type: tpl.type, typeData: tpl.typeData, canEdit: tpl.canEdit ?? 0, isActive: tpl.isActive ?? 1, isExist: tpl.isExist, }, }); } } private toSmallInt(value: unknown, fallback: number) { if (value === 0 || value === false || value === '0') return 0; if (value === 1 || value === true || value === '1') return 1; if (typeof value === 'number' && Number.isFinite(value)) return value; return fallback; } private async requireKhanegiTenant(tenantId: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant) throw new NotFoundException('نسخه یافت نشد'); if (tenant.product !== ProductType.khanegi) { throw new ForbiddenException('تنظیمات برنامه فقط برای خانگی است'); } return tenant; } private tenantMeta(tenant: { id: string; nameFa: string; slug: string; domain: string; product: ProductType; appUrl: string | null; adminUrl: string | null; uiVariant: string; nodeServer: string; }) { return { id: tenant.id, nameFa: tenant.nameFa, slug: tenant.slug, domain: tenant.domain, product: tenant.product, appUrl: tenant.appUrl, adminUrl: tenant.adminUrl, uiVariant: tenant.uiVariant, nodeServer: tenant.nodeServer, }; } 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: { domain: string }) => { const d = this.normalizeHost(item.domain); return d === host || d.replace(/^www\./, '') === hostNoWww; }); const tenant = primary || tenants.find((item: { domain: string; domains: unknown }) => { 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; domain: string }, mobile: string, password: string, ) { const apiBase = this.normalizeApiBase(tenant.nodeServer); if (!apiBase) { throw new BadRequestException('آدرس API نسخه (node_server) تنظیم نشده'); } // همان مسیر پنل ادمین خانگی: POST /auth/login با provider=user const url = this.joinUrl(apiBase, '/auth/login'); try { const res = await axios.post( url, { provider: 'user', mobile, password }, { timeout: 25000, validateStatus: () => true, headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, }, ); const data = res.data; if (res.status >= 400 || data?.status === false || data?.success === false) { throw new BadRequestException( this.laravelMessage(data) || 'ورود ادمین خانگی ناموفق بود', ); } const token = this.extractAccessToken(data); if (!token) { throw new BadRequestException('توکن ادمین خانگی دریافت نشد'); } return { apiBase, token }; } catch (error) { if (error instanceof HttpException) throw error; throw new BadGatewayException( this.axiosMessage(error, 'اتصال به لاگین ادمین خانگی ناموفق بود'), ); } } private async fetchSystemSettings(apiBase: string, token: string) { const headers = { Authorization: `Bearer ${token}` }; const url = this.joinUrl(apiBase, '/admin/settings/table'); try { const { data } = await axios.get(url, { headers, timeout: 25000, }); 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; if (status === 401 || status === 403) { throw new BadRequestException( this.axiosMessage(error, 'توکن ادمین خانگی برای settings/table رد شد'), ); } } try { const { data } = await axios.get(this.joinUrl(apiBase, '/admin/get_settings'), { headers, params: { system_only: 1, skip_overlay: 1 }, timeout: 25000, }); return this.asSettingsArray(data) .filter((row) => this.isSystemSetting(row)) .filter((row) => !isRuntimeProgramSettingKey(String(row.key || ''))); } catch (error) { throw new BadGatewayException( this.axiosMessage(error, 'خواندن جدول settings از اپ خانگی ناموفق بود'), ); } } private normalizeAdminMobile(mobile: string) { let m = String(mobile || '').replace(/\D/g, ''); if (m.startsWith('98') && m.length === 12) m = `0${m.slice(2)}`; return m; } private normalizeApiBase(raw: string) { const s = String(raw || '').trim().replace(/\/$/, ''); if (!s) return ''; if (/^https?:\/\//i.test(s)) return s; return `https://${s}`; } private normalizeHost(value?: string | null) { if (!value) return ''; return String(value) .trim() .toLowerCase() .replace(/^https?:\/\//, '') .split('/')[0] .split(':')[0]; } private joinUrl(base: string, path: string) { return `${base.replace(/\/$/, '')}${path}`; } private extractAccessToken(data: unknown): string { if (!data || typeof data !== 'object') return ''; const obj = data as Record; const nested = obj.data && typeof obj.data === 'object' ? (obj.data as Record) : null; const raw = obj.access_token ?? nested?.access_token ?? obj.token ?? nested?.token; return String(raw || '').trim(); } private asSettingsArray(data: unknown): ProgramSettingRow[] { if (Array.isArray(data)) return data as ProgramSettingRow[]; if (data && typeof data === 'object') { const obj = data as Record; if (Array.isArray(obj.data)) return obj.data as ProgramSettingRow[]; if (Array.isArray(obj.settings)) return obj.settings as ProgramSettingRow[]; if (Array.isArray(obj.items)) return obj.items as ProgramSettingRow[]; } return []; } private isSystemSetting(row: ProgramSettingRow): boolean { const canEdit = row?.can_edit; const locked = canEdit === 0 || canEdit === false || canEdit === '0'; if (!locked) return false; const isActive = row?.is_active; if (isActive === 0 || isActive === false || isActive === '0') return false; return isActive !== null && isActive !== undefined; } private laravelMessage(data: unknown): string { if (!data || typeof data !== 'object') { return typeof data === 'string' ? data : ''; } const obj = data as Record; if (typeof obj.message === 'string' && obj.message.trim()) return obj.message; if (Array.isArray(obj.message)) return obj.message.map(String).join('، '); if (obj.errors && typeof obj.errors === 'object') { const parts = Object.values(obj.errors as Record) .flat() .map(String) .filter(Boolean); if (parts.length) return parts.join('، '); } return ''; } private axiosMessage(error: unknown, fallback: string) { if (axios.isAxiosError(error)) { const status = error.response?.status; const body = error.response?.data; const fromBody = this.laravelMessage(body); if (fromBody) { return `${fallback}${status ? ` (HTTP ${status})` : ''}: ${fromBody}`; } return `${fallback}${status ? ` (HTTP ${status})` : ''}`; } if (error instanceof Error && error.message) return `${fallback}: ${error.message}`; return fallback; } }