2026-08-17 15:02:22 +03:30
import {
BadGatewayException ,
BadRequestException ,
ForbiddenException ,
HttpException ,
Injectable ,
2026-08-17 20:14:17 +03:30
Logger ,
2026-08-17 15:02:22 +03:30
NotFoundException ,
2026-08-17 20:14:17 +03:30
OnModuleInit ,
2026-08-17 15:02:22 +03:30
} from '@nestjs/common' ;
import axios from 'axios' ;
2026-08-17 20:14:17 +03:30
import { existsSync , readFileSync } from 'fs' ;
import { join } from 'path' ;
import { Prisma , ProductType , Setting , SettingTemplate } from '@prisma/client' ;
2026-08-17 15:02:22 +03:30
import { PrismaService } from '../prisma/prisma.service' ;
import { AuditService } from '../audit/audit.service' ;
2026-08-17 17:07:46 +03:30
import { isSecretOrServerOnlySettingKey } from './secret-setting-keys' ;
import { isRuntimeProgramSettingKey } from './runtime-setting-keys' ;
2026-08-17 21:51:48 +03:30
import { DEPLOY_SETTING_CATALOG_KEYS } from './deploy-setting-catalog-keys' ;
2026-08-17 15:02:22 +03:30
export type ProgramSettingRow = {
2026-08-17 20:14:17 +03:30
id? : string | number ;
2026-08-17 15:02:22 +03:30
key? : string ;
value? : string | number | boolean | null ;
title_fa? : string | null ;
description? : string | null ;
type ?: string | null ;
2026-08-17 20:14:17 +03:30
type_data? : string | null ;
2026-08-17 15:02:22 +03:30
is_active? : number | boolean | string | null ;
can_edit? : number | boolean | string | null ;
2026-08-17 20:14:17 +03:30
is_exist? : number | boolean | string | null ;
2026-08-17 15:02:22 +03:30
};
2026-08-17 20:14:17 +03:30
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 ;
2026-08-17 15:02:22 +03:30
};
@Injectable ()
2026-08-17 20:14:17 +03:30
export class ProgramSettingsService implements OnModuleInit {
private readonly logger = new Logger ( ProgramSettingsService . name );
2026-08-17 15:02:22 +03:30
constructor (
private readonly prisma : PrismaService ,
private readonly audit : AuditService ,
) {}
2026-08-17 20:14:17 +03:30
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 } ` );
}
}
2026-08-17 15:02:22 +03:30
async get ( tenantId : string ) {
const tenant = await this . requireKhanegiTenant ( tenantId );
2026-08-17 20:14:17 +03:30
const items = await this . listTenantRows ( tenantId );
2026-08-17 15:02:22 +03:30
return {
tenant : this.tenantMeta ( tenant ),
2026-08-17 20:14:17 +03:30
items ,
count : items.length ,
2026-08-17 17:07:46 +03:30
};
}
async getPublicByHost ( hostHeader? : string , domainQuery? : string ) {
const tenant = await this . findKhanegiTenantByHost ( domainQuery || hostHeader || '' );
2026-08-17 20:14:17 +03:30
const items = ( await this . listTenantRows ( tenant . id )). filter (
2026-08-17 17:07:46 +03:30
( row ) => ! isSecretOrServerOnlySettingKey ( String ( row . key || '' )),
);
return {
settings : items ,
2026-08-17 20:14:17 +03:30
updatedAt : null ,
2026-08-17 17:07:46 +03:30
count : items.length ,
};
}
async getRemoteFull ( tenantId : string ) {
const tenant = await this . requireKhanegiTenant ( tenantId );
2026-08-17 20:14:17 +03:30
const items = await this . listTenantRows ( tenant . id );
2026-08-17 17:07:46 +03:30
return {
2026-08-17 20:14:17 +03:30
settings : items ,
updatedAt : null ,
count : items.length ,
2026-08-17 17:07:46 +03:30
};
}
async updateItems (
tenantId : string ,
itemsRaw : Array < Record < string , unknown >>,
userId? : string ,
) {
const tenant = await this . requireKhanegiTenant ( tenantId );
const incoming = this . asSettingsArray ( itemsRaw )
2026-08-17 20:14:17 +03:30
. map (( row ) => this . normalizeIncoming ( row ))
2026-08-17 17:07:46 +03:30
. filter (( row ) => row . key && ! isRuntimeProgramSettingKey ( row . key ));
if ( ! incoming . length ) {
throw new BadRequestException ( 'هیچ تنظیم معتبری برای ذخیره ارسال نشده' );
}
2026-08-17 20:14:17 +03:30
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 ) },
});
2026-08-17 17:07:46 +03:30
});
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 ,
});
2026-08-17 20:14:17 +03:30
const items = await this . listTenantRows ( tenantId );
2026-08-17 17:07:46 +03:30
return {
tenant : this.tenantMeta ( tenant ),
2026-08-17 20:14:17 +03:30
items ,
count : items.length ,
2026-08-17 15:02:22 +03:30
};
}
2026-08-17 20:14:17 +03:30
/** پیشنمایش ایمپورت — برای هر کلید تصمیمگیری لازم است */
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 );
2026-08-17 21:51:48 +03:30
await this . ensureTemplatesSeeded ();
2026-08-17 20:14:17 +03:30
const remote = await this . fetchSystemSettings ( apiBase , token );
const local = await this . listTenantRows ( tenantId );
2026-08-17 21:51:48 +03:30
const catalogKeys = await this . catalogKeySet ();
2026-08-17 20:14:17 +03:30
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 ) {
2026-08-17 21:51:48 +03:30
if ( ! this . isNewKhanegiCatalogKey ( key , catalogKeys )) continue ;
2026-08-17 20:14:17 +03:30
const localRow = localByKey . get ( key );
decisionsNeeded . push ({
key ,
title_fa : String ( row . title_fa || localRow ? . title_fa || key ),
khanegi_value : String ( row . value ?? '' ),
superadmin_value : localRow ? String ( localRow . value ?? '' ) : null ,
status : localRow ? 'both' : 'new_in_khanegi' ,
});
}
2026-08-17 21:51:48 +03:30
const onlySuperAdmin = [... catalogKeys ]. filter (( key ) => ! remoteByKey . has ( key ));
2026-08-17 20:14:17 +03:30
return {
tenant : this.tenantMeta ( tenant ),
apiBase ,
decisionsNeeded ,
onlySuperAdminKeys : onlySuperAdmin ,
2026-08-17 21:51:48 +03:30
skippedCatalogCount : [... remoteByKey . keys ()]. filter (( key ) => catalogKeys . has ( key )). length ,
2026-08-17 20:14:17 +03:30
note :
2026-08-17 21:51:48 +03:30
'فقط کلیدهایی که در قالب سوپرادمین نیستند. کلیدهای deploy_settings_const از قبل در قالباند و از خانگی بازنویسی نمیشوند. کلیدهای فقط-سوپرادمین روی این پروژه با پیشفرض قالب تکمیل میشوند.' ,
2026-08-17 20:14:17 +03:30
};
}
async applyImport (
2026-08-17 15:02:22 +03:30
tenantId : string ,
mobile : string ,
password : string ,
2026-08-17 20:14:17 +03:30
decisions : ImportDecision [],
2026-08-17 15:02:22 +03:30
userId? : string ,
) {
const tenant = await this . requireKhanegiTenant ( tenantId );
const mobileNorm = this . normalizeAdminMobile ( mobile );
if ( ! /^\d{11}$/ . test ( mobileNorm )) {
throw new BadRequestException ( 'موبایل ادمین خانگی باید ۱۱ رقم باشد (مثلاً 0912…)' );
}
2026-08-17 20:14:17 +03:30
const { apiBase , token } = await this . loginKhanegiAdmin ( tenant , mobileNorm , password );
2026-08-17 21:51:48 +03:30
await this . ensureTemplatesSeeded ();
2026-08-17 20:14:17 +03:30
const remote = await this . fetchSystemSettings ( apiBase , token );
2026-08-17 21:51:48 +03:30
const catalogKeys = await this . catalogKeySet ();
2026-08-17 20:14:17 +03:30
const remoteByKey = new Map (
remote
. map (( row ) => this . normalizeIncoming ( row ))
. filter (( row ) => row . key && ! isRuntimeProgramSettingKey ( row . key ))
2026-08-17 21:51:48 +03:30
. filter (( row ) => this . isNewKhanegiCatalogKey ( row . key , catalogKeys ))
2026-08-17 20:14:17 +03:30
. map (( row ) => [ row . key , row ]),
2026-08-17 15:02:22 +03:30
);
2026-08-17 20:14:17 +03:30
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 scope = decisionMap . get ( key ) ?? 'none' ;
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 ,
});
}
// کلید جدید از خانگی → وارد قالب هم بشود اگر نبود
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 ) },
});
2026-08-17 15:02:22 +03:30
});
await this . audit . log ({
action : 'tenant.program_settings.import' ,
entityType : 'tenant_program_settings' ,
entityId : tenantId ,
tenantId ,
userId ,
after : {
2026-08-17 20:14:17 +03:30
decisions : [... decisionMap . entries ()]. map (([ key , scope ]) => ({ key , scope })),
apiBase ,
2026-08-17 15:02:22 +03:30
} as Prisma . InputJsonValue ,
});
2026-08-17 20:14:17 +03:30
const items = await this . listTenantRows ( tenantId );
2026-08-17 15:02:22 +03:30
return {
tenant : this.tenantMeta ( tenant ),
apiBase ,
items ,
count : items.length ,
};
}
2026-08-17 20:14:17 +03:30
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 ,
});
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 ,
});
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() {
2026-08-17 21:51:48 +03:30
const raw = this . readSeedRows ();
const before = await this . prisma . settingTemplate . count ();
if ( ! raw . length ) {
return { seeded : false , count : before , missingSeed : true };
2026-08-17 20:14:17 +03:30
}
2026-08-17 21:51:48 +03:30
let inserted = 0 ;
2026-08-17 20:14:17 +03:30
await this . prisma . $transaction ( async ( tx : any ) => {
for ( const row of raw ) {
const key = String ( row . key || '' ). trim ();
if ( ! key || isRuntimeProgramSettingKey ( key )) continue ;
2026-08-17 21:51:48 +03:30
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 : {
2026-08-17 20:14:17 +03:30
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 ,
},
});
2026-08-17 21:51:48 +03:30
inserted += 1 ;
2026-08-17 20:14:17 +03:30
}
});
const after = await this . prisma . settingTemplate . count ();
2026-08-17 21:51:48 +03:30
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 [];
}
}
/** کلیدهای deploy const (داخل باندل) + فایل seed + جدول قالب. */
private async catalogKeySet() {
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 );
}
const rows = await this . prisma . settingTemplate . findMany ({
where : { deletedAt : null },
select : { key : true },
});
for ( const row of rows as Array < { key : string } > ) keys . add ( row . key );
return keys ;
}
private isNewKhanegiCatalogKey ( key : string , catalogKeys : Set < string >) {
if ( ! key || catalogKeys . has ( key )) return false ;
if ( isRuntimeProgramSettingKey ( key )) return false ;
if ( isSecretOrServerOnlySettingKey ( key )) return false ;
return true ;
2026-08-17 20:14:17 +03:30
}
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 ;
}
2026-08-17 15:02:22 +03:30
private async requireKhanegiTenant ( tenantId : string ) {
const tenant = await this . prisma . tenant . findUnique ({ where : { id : tenantId } });
2026-08-17 20:14:17 +03:30
if ( ! tenant ) throw new NotFoundException ( 'نسخه یافت نشد' );
2026-08-17 15:02:22 +03:30
if ( tenant . product !== ProductType . khanegi ) {
2026-08-17 20:14:17 +03:30
throw new ForbiddenException ( 'تنظیمات برنامه فقط برای خانگی است' );
2026-08-17 15:02:22 +03:30
}
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 ,
};
}
2026-08-17 17:07:46 +03:30
private async findKhanegiTenantByHost ( hostRaw : string ) {
const host = this . normalizeHost ( hostRaw );
2026-08-17 20:14:17 +03:30
if ( ! host ) throw new NotFoundException ( 'domain required' );
2026-08-17 17:07:46 +03:30
const tenants = await this . prisma . tenant . findMany ({
where : { isActive : true , product : ProductType.khanegi },
});
const hostNoWww = host . replace ( /^www\./ , '' );
2026-08-17 20:14:17 +03:30
const primary = tenants . find (( item : { domain : string }) => {
2026-08-17 17:07:46 +03:30
const d = this . normalizeHost ( item . domain );
return d === host || d . replace ( /^www\./ , '' ) === hostNoWww ;
});
const tenant =
primary ||
2026-08-17 20:14:17 +03:30
tenants . find (( item : { domain : string ; domains : unknown }) => {
2026-08-17 17:07:46 +03:30
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 ;
});
2026-08-17 20:14:17 +03:30
if ( ! tenant ) throw new NotFoundException ( 'tenant not found for domain' );
2026-08-17 17:07:46 +03:30
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' ;
2026-08-17 20:14:17 +03:30
if ( /^\d+$/ . test ( v )) return String ( Number ( v ) + 1 );
2026-08-17 17:07:46 +03:30
const semver = v . match ( /^(\d+)\.(\d+)\.(\d+)$/ );
2026-08-17 20:14:17 +03:30
if ( semver ) return ` ${ semver [ 1 ] } . ${ semver [ 2 ] } . ${ Number ( semver [ 3 ]) + 1 } ` ;
2026-08-17 17:07:46 +03:30
return ` ${ Date . now () } ` ;
}
2026-08-17 15:02:22 +03:30
private async loginKhanegiAdmin (
2026-08-17 20:14:17 +03:30
tenant : { nodeServer : string ; domain : string },
2026-08-17 15:02:22 +03:30
mobile : string ,
password : string ,
2026-08-17 20:14:17 +03:30
) {
const apiBase = this . normalizeApiBase ( tenant . nodeServer );
if ( ! apiBase ) {
throw new BadRequestException ( 'آدرس API نسخه (node_server) تنظیم نشده' );
}
2026-08-17 21:39:45 +03:30
// همان مسیر پنل ادمین خانگی: POST /auth/login با provider=user
const url = this . joinUrl ( apiBase , '/auth/login' );
2026-08-17 20:14:17 +03:30
try {
2026-08-17 21:39:45 +03:30
const res = await axios . post (
2026-08-17 20:14:17 +03:30
url ,
2026-08-17 21:39:45 +03:30
{ provider : 'user' , mobile , password },
{
timeout : 25000 ,
validateStatus : () => true ,
headers : {
Accept : 'application/json' ,
'Content-Type' : 'application/json' ,
},
},
2026-08-17 20:14:17 +03:30
);
2026-08-17 21:39:45 +03:30
const data = res . data ;
if ( res . status >= 400 || data ? . status === false || data ? . success === false ) {
2026-08-17 20:14:17 +03:30
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 , 'اتصال به لاگین ادمین خانگی ناموفق بود' ),
2026-08-17 15:02:22 +03:30
);
}
}
private async fetchSystemSettings ( apiBase : string , token : string ) {
2026-08-17 20:14:17 +03:30
const headers = { Authorization : `Bearer ${ token } ` };
2026-08-17 15:02:22 +03:30
const url = this . joinUrl ( apiBase , '/admin/get_settings' );
try {
const { data } = await axios . get ( url , {
headers ,
2026-08-17 20:14:17 +03:30
params : { system_only : 1 },
2026-08-17 15:02:22 +03:30
timeout : 25000 ,
});
2026-08-17 17:07:46 +03:30
const items = this . asSettingsArray ( data )
. filter (( row ) => this . isSystemSetting ( row ))
. filter (( row ) => ! isRuntimeProgramSettingKey ( String ( row . key || '' )));
2026-08-17 15:02:22 +03:30
if ( items . length ) return items ;
} catch ( error ) {
const status = axios . isAxiosError ( error ) ? error.response?.status : undefined ;
if ( status === 401 || status === 403 ) {
2026-08-17 20:14:17 +03:30
throw new BadRequestException (
2026-08-17 15:02:22 +03:30
this . axiosMessage ( error , 'توکن ادمین خانگی برای get_settings رد شد' ),
);
}
}
try {
const { data } = await axios . get ( url , { headers , timeout : 25000 });
2026-08-17 17:07:46 +03:30
return this . asSettingsArray ( data )
. filter (( row ) => this . isSystemSetting ( row ))
. filter (( row ) => ! isRuntimeProgramSettingKey ( String ( row . key || '' )));
2026-08-17 15:02:22 +03:30
} catch ( error ) {
throw new BadGatewayException (
this . axiosMessage ( error , 'خواندن get_settings از اپ خانگی ناموفق بود' ),
);
}
}
2026-08-17 20:14:17 +03:30
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 ;
2026-08-17 15:02:22 +03:30
}
2026-08-17 20:14:17 +03:30
private normalizeApiBase ( raw : string ) {
const s = String ( raw || '' ). trim (). replace ( /\/$/ , '' );
if ( ! s ) return '' ;
if ( /^https?:\/\//i . test ( s )) return s ;
return `https:// ${ s } ` ;
2026-08-17 15:02:22 +03:30
}
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 } ` ;
}
2026-08-17 20:14:17 +03:30
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 ;
}
2026-08-17 15:02:22 +03:30
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 } )` : '' } ` ;
}
2026-08-17 20:14:17 +03:30
if ( error instanceof Error && error . message ) return ` ${ fallback } : ${ error . message } ` ;
2026-08-17 15:02:22 +03:30
return fallback ;
}
}