Files
api/src/program-settings/program-settings.service.ts
T

943 lines
31 KiB
TypeScript
Raw Normal View History

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 } 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)`);
}
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<Record<string, unknown>>,
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));
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<string, ImportDecisionScope>();
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) {
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 },
orderBy: { key: 'asc' },
});
return {
items: rows.map((row: SettingTemplate) => this.toApiRow(row)),
count: rows.length,
};
}
/** افزودن کلید(ها) به قالب + همهٔ نسخه‌های خانگی */
async addKeys(itemsRaw: Array<Record<string, unknown>>, userId?: string) {
const incoming = this.asSettingsArray(itemsRaw)
.map((row) => this.normalizeIncoming(row))
.filter((row) => row.key && !isRuntimeProgramSettingKey(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 },
});
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;
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 [];
}
}
/**
* قالب ایمپورت = کلیدهای INSERT در deploy_settings_const.sql
* (باندل + JSON هم‌تراز همان فایل). جدول setting_templates منبع قالب نیست.
*/
private deployCatalogKeySet() {
const keys = new Set<string>(DEPLOY_SETTING_CATALOG_KEYS);
for (const row of this.readSeedRows()) {
const key = String(row.key || '').trim();
if (key) keys.add(key);
}
return keys;
}
private deployCatalogCanEditMap() {
const map = new Map<string, number>();
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<ProgramSettingRow[]> {
const rows = await this.prisma.setting.findMany({
where: { tenantId, deletedAt: null },
orderBy: { key: 'asc' },
});
return rows
.filter((row: Setting) => !isRuntimeProgramSettingKey(row.key))
.map((row: Setting) => this.toApiRow(row));
}
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<string, unknown>) {
const r = row as Record<string, unknown>;
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<ProgramSettingsService['normalizeIncoming']>) {
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<ProgramSettingsService['normalizeIncoming']>,
) {
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 } });
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<string, unknown>;
const nested =
obj.data && typeof obj.data === 'object'
? (obj.data as Record<string, unknown>)
: 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<string, unknown>;
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<string, unknown>;
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<string, unknown>)
.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;
}
}