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'; 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'; 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: stored?.items ?? [], count: stored?.items?.length ?? 0, }; } 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 }, }); 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: raw.filter((row) => this.isSystemSetting(row as ProgramSettingRow)), }; } return null; } const obj = raw as Record; const items = this.asSettingsArray(obj.items ?? obj); return { importedAt: String(obj.importedAt || ''), source: 'khanegi_admin', apiBase: String(obj.apiBase || ''), items, }; } 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)); 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)); } 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; 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 { 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; 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; } }