From f2cf8c9035535c4f649adc363b58dda3f011e54e Mon Sep 17 00:00:00 2001 From: adonis Date: Mon, 17 Aug 2026 15:02:22 +0330 Subject: [PATCH] feat(tenants): store Khanegi deploy settings snapshot on tenants.setting Import can_edit=0 settings via the sold version's admin login + get_settings, for Super Admin program-settings UI. Co-authored-by: Cursor --- .../migration.sql | 4 + prisma/schema.prisma | 2 + src/app.module.ts | 2 + .../dto/import-program-settings.dto.ts | 13 + .../program-settings.controller.ts | 34 ++ .../program-settings.module.ts | 12 + .../program-settings.service.ts | 385 ++++++++++++++++++ src/tenants/tenants.service.ts | 7 +- 8 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260817120000_tenant_setting/migration.sql create mode 100644 src/program-settings/dto/import-program-settings.dto.ts create mode 100644 src/program-settings/program-settings.controller.ts create mode 100644 src/program-settings/program-settings.module.ts create mode 100644 src/program-settings/program-settings.service.ts diff --git a/prisma/migrations/20260817120000_tenant_setting/migration.sql b/prisma/migrations/20260817120000_tenant_setting/migration.sql new file mode 100644 index 0000000..f16d279 --- /dev/null +++ b/prisma/migrations/20260817120000_tenant_setting/migration.sql @@ -0,0 +1,4 @@ +-- Snapshot of Khanegi deploy settings (can_edit=0) per sold version + +ALTER TABLE "tenants" + ADD COLUMN IF NOT EXISTS "setting" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 37eb09a..f6c1e25 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,6 +69,8 @@ model Tenant { /// Legacy cache from older sync attempts — SuperAdmin SMS account is now source of truth smsSettingsCache Json? @map("sms_settings_cache") smsSyncedAt DateTime? @map("sms_synced_at") + /// Khanegi deploy settings snapshot (can_edit=0) imported via admin login + get_settings. + setting Json? /// Full Khanegi UI JSON (theme, navigation, pages). Hamkar: unused for now. appUiConfig Json? @map("app_ui_config") /// Khanegi only: which appearance editor / get_user payload shape to use. diff --git a/src/app.module.ts b/src/app.module.ts index 63f8cfe..3ea413e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ import { GetUserModule } from './get-user/get-user.module'; import { GetVersionModule } from './get-version/get-version.module'; import { GetManifestModule } from './get-manifest/get-manifest.module'; import { SettingsProxyModule } from './settings-proxy/settings-proxy.module'; +import { ProgramSettingsModule } from './program-settings/program-settings.module'; import { AuditModule } from './audit/audit.module'; import { SmsModule } from './sms/sms.module'; import { ServersModule } from './servers/servers.module'; @@ -27,6 +28,7 @@ import { HealthController } from './health.controller'; GetVersionModule, GetManifestModule, SettingsProxyModule, + ProgramSettingsModule, AuditModule, SmsModule, ServersModule, diff --git a/src/program-settings/dto/import-program-settings.dto.ts b/src/program-settings/dto/import-program-settings.dto.ts new file mode 100644 index 0000000..4acea50 --- /dev/null +++ b/src/program-settings/dto/import-program-settings.dto.ts @@ -0,0 +1,13 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class ImportProgramSettingsDto { + @IsString() + @MinLength(3) + @MaxLength(32) + mobile!: string; + + @IsString() + @MinLength(1) + @MaxLength(120) + password!: string; +} diff --git a/src/program-settings/program-settings.controller.ts b/src/program-settings/program-settings.controller.ts new file mode 100644 index 0000000..37bd1d8 --- /dev/null +++ b/src/program-settings/program-settings.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Param, 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 { ProgramSettingsService } from './program-settings.service'; + +@Controller('tenants/:tenantId/program-settings') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class ProgramSettingsController { + constructor(private readonly programSettings: ProgramSettingsService) {} + + @Get() + @RequirePermissions('khanegi:read') + get(@Param('tenantId') tenantId: string) { + return this.programSettings.get(tenantId); + } + + @Post('import') + @RequirePermissions('khanegi:write') + import( + @Param('tenantId') tenantId: string, + @Body() body: ImportProgramSettingsDto, + @CurrentUser() user: { id: string }, + ) { + return this.programSettings.importFromKhanegi( + tenantId, + body.mobile, + body.password, + user.id, + ); + } +} diff --git a/src/program-settings/program-settings.module.ts b/src/program-settings/program-settings.module.ts new file mode 100644 index 0000000..52b1eeb --- /dev/null +++ b/src/program-settings/program-settings.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ProgramSettingsController } from './program-settings.controller'; +import { ProgramSettingsService } from './program-settings.service'; + +@Module({ + imports: [AuthModule], + controllers: [ProgramSettingsController], + providers: [ProgramSettingsService], + exports: [ProgramSettingsService], +}) +export class ProgramSettingsModule {} diff --git a/src/program-settings/program-settings.service.ts b/src/program-settings/program-settings.service.ts new file mode 100644 index 0000000..148d72a --- /dev/null +++ b/src/program-settings/program-settings.service.ts @@ -0,0 +1,385 @@ +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; + } +} diff --git a/src/tenants/tenants.service.ts b/src/tenants/tenants.service.ts index 253434f..8c1c934 100644 --- a/src/tenants/tenants.service.ts +++ b/src/tenants/tenants.service.ts @@ -38,6 +38,7 @@ export class TenantsService { appUrl: true, adminUrl: true, appUiConfig: true, + setting: true, uiVariant: true, appVersion: true, isActive: true, @@ -47,9 +48,10 @@ export class TenantsService { }, }); - return rows.map(({ appUiConfig, ...rest }) => ({ + return rows.map(({ appUiConfig, setting, ...rest }) => ({ ...rest, hasAppUiConfig: Boolean(appUiConfig), + hasProgramSettings: Boolean(setting), })); } @@ -234,10 +236,11 @@ export class TenantsService { } private sanitize(tenant: Tenant) { - const { remoteApiToken, ...rest } = tenant; + const { remoteApiToken, setting, ...rest } = tenant; return { ...rest, hasRemoteApiToken: Boolean(remoteApiToken), + hasProgramSettings: Boolean(setting), }; }