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

556 lines
17 KiB
TypeScript
Raw Normal View History

import {
BadGatewayException,
BadRequestException,
ForbiddenException,
HttpException,
Injectable,
NotFoundException,
} from '@nestjs/common';
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;
key?: string;
value?: string | number | boolean | null;
title_fa?: string | null;
description?: string | null;
type?: string | null;
is_active?: number | boolean | string | null;
can_edit?: number | boolean | string | null;
};
export type ProgramSettingsPayload = {
importedAt: string;
source: 'khanegi_admin' | 'superadmin';
apiBase: string;
items: ProgramSettingRow[];
};
@Injectable()
export class ProgramSettingsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async get(tenantId: string) {
const tenant = await this.requireKhanegiTenant(tenantId);
const stored = this.readStored(tenant.setting);
return {
tenant: this.tenantMeta(tenant),
importedAt: stored?.importedAt ?? null,
source: stored?.source ?? null,
apiBase: stored?.apiBase ?? null,
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,
};
}
async importFromKhanegi(
tenantId: string,
mobile: string,
password: string,
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,
);
const items = await this.fetchSystemSettings(apiBase, token);
const payload: ProgramSettingsPayload = {
importedAt: new Date().toISOString(),
source: 'khanegi_admin',
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.import',
entityType: 'tenant_program_settings',
entityId: tenantId,
tenantId,
userId,
after: {
importedAt: payload.importedAt,
count: items.length,
keys: items.map((row) => row.key).filter(Boolean),
} as Prisma.InputJsonValue,
meta: { apiBase } as Prisma.InputJsonValue,
});
return {
tenant: this.tenantMeta(tenant),
importedAt: payload.importedAt,
source: payload.source,
apiBase,
items,
count: items.length,
};
}
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 readStored(raw: Prisma.JsonValue | null): ProgramSettingsPayload | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
if (Array.isArray(raw)) {
return {
importedAt: '',
source: 'khanegi_admin',
apiBase: '',
items: this.asSettingsArray(raw).filter((row) =>
this.isSystemSetting(row),
),
};
}
return null;
}
const obj = raw as Record<string, unknown>;
const items = this.asSettingsArray(obj.items ?? obj);
return {
importedAt: String(obj.importedAt || ''),
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;
domain: string;
},
mobile: string,
password: string,
): Promise<{ apiBase: string; token: string }> {
const candidates = this.apiBaseCandidates(tenant);
if (!candidates.length) {
throw new BadRequestException(
'آدرس API نسخه (node_server) تنظیم نشده؛ اول در مدیریت نسخه ذخیره کنید.',
);
}
let lastError: unknown;
for (const apiBase of candidates) {
try {
const res = await axios.post(
this.joinUrl(apiBase, '/auth/login'),
{ mobile: mobile.trim(), password, provider: 'user' },
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
timeout: 20000,
validateStatus: () => true,
},
);
if (res.status === 404 || res.status === 405) {
lastError = new BadGatewayException(`ورود روی ${apiBase} یافت نشد`);
continue;
}
if (res.status >= 500) {
lastError = new BadGatewayException(
this.laravelMessage(res.data) || `سرور خانگی ${apiBase} خطا داد`,
);
continue;
}
if (res.status === 401 || res.status === 422 || res.status === 400) {
throw new BadGatewayException(
this.laravelMessage(res.data) ||
'نام کاربری یا رمز عبور ادمین خانگی اشتباه است',
);
}
const token = this.extractAccessToken(res.data);
if (token) {
return { apiBase, token };
}
lastError = new BadGatewayException(
this.laravelMessage(res.data) ||
`ورود به ادمین خانگی از ${apiBase} توکن برنگرداند`,
);
} catch (error) {
if (error instanceof HttpException) throw error;
lastError = error;
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
if (status === 401 || status === 422 || status === 400) {
throw new BadGatewayException(
this.axiosMessage(error, 'نام کاربری یا رمز عبور ادمین خانگی اشتباه است'),
);
}
}
}
throw new BadGatewayException(
this.axiosMessage(
lastError,
'ورود به API ادمین خانگی ناموفق بود — node_server و دامنه نسخه را چک کنید',
),
);
}
private async fetchSystemSettings(apiBase: string, token: string) {
const url = this.joinUrl(apiBase, '/admin/get_settings');
const headers = {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
};
try {
const { data } = await axios.get(url, {
params: { system_only: 1 },
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 BadGatewayException(
this.axiosMessage(error, 'توکن ادمین خانگی برای get_settings رد شد'),
);
}
}
try {
const { data } = await axios.get(url, { headers, 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, 'خواندن get_settings از اپ خانگی ناموفق بود'),
);
}
}
private normalizeAdminMobile(mobile: string): string {
let digits = String(mobile || '').replace(/\D+/g, '');
if (digits.startsWith('98') && digits.length >= 12) {
digits = digits.slice(2);
}
if (digits.length === 10 && digits.startsWith('9')) {
digits = `0${digits}`;
}
return digits;
}
private apiBaseCandidates(tenant: { nodeServer: string; domain: string }): string[] {
const out: string[] = [];
const push = (value?: string | null) => {
const trimmed = String(value || '')
.trim()
.replace(/\/$/, '');
if (trimmed && !out.includes(trimmed)) out.push(trimmed);
};
push(tenant.nodeServer);
const host = this.normalizeHost(tenant.domain);
if (host) {
push(`https://${host}/server`);
push(`https://${host}/api`);
push(`https://${host}`);
}
return out;
}
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 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') {
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 {
if (!this.isLocked(row?.can_edit)) return false;
return this.isActive(row?.is_active);
}
private isLocked(canEdit: ProgramSettingRow['can_edit']): boolean {
return canEdit === 0 || canEdit === false || canEdit === '0';
}
private isActive(isActive: ProgramSettingRow['is_active']): boolean {
if (isActive === 0 || isActive === false || isActive === '0') return false;
return isActive !== null && isActive !== undefined;
}
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 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;
}
}