Merge tenant values with the deploy template catalog so every system setting (e.g. all montzar_* can_edit=0 keys) appears like khaneghi advanced settings, not only rows already stored for the tenant. Co-authored-by: Cursor <cursoragent@cursor.com>
1023 lines
33 KiB
TypeScript
1023 lines
33 KiB
TypeScript
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,
|
|
DEPLOY_SETTING_EDITABLE_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)`);
|
|
}
|
|
const purged = await this.purgeEditableFromSuperAdmin();
|
|
if (purged.templates || purged.settings) {
|
|
this.logger.log(
|
|
`purged can_edit=1 from superadmin (templates=${purged.templates}, settings=${purged.settings})`,
|
|
);
|
|
}
|
|
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))
|
|
.filter((row) => this.isSystemProgramSettingKey(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) {
|
|
if (!this.isSystemProgramSettingKey(key)) continue;
|
|
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,
|
|
key: { in: [...DEPLOY_SETTING_CATALOG_KEYS] },
|
|
},
|
|
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))
|
|
.filter((row) => this.isSystemProgramSettingKey(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,
|
|
key: { in: [...DEPLOY_SETTING_CATALOG_KEYS] },
|
|
},
|
|
});
|
|
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;
|
|
if (!this.isSystemProgramSettingKey(key)) continue;
|
|
if (this.toSmallInt(row.can_edit, 0) !== 0) 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 [];
|
|
}
|
|
}
|
|
|
|
/** فقط can_edit=0 نهایی deploy_settings_const — نه کل INSERTها */
|
|
private deployCatalogKeySet() {
|
|
return new Set<string>(DEPLOY_SETTING_CATALOG_KEYS);
|
|
}
|
|
|
|
private isSystemProgramSettingKey(key: string) {
|
|
const k = String(key || '').trim();
|
|
if (!k || isRuntimeProgramSettingKey(k)) return false;
|
|
if (DEPLOY_SETTING_EDITABLE_KEYS.has(k)) return false;
|
|
return DEPLOY_SETTING_CATALOG_KEYS.has(k);
|
|
}
|
|
|
|
/** حذف can_edit=1 از قالب و همهٔ پروژههای خانگی در DB سوپرادمین */
|
|
async purgeEditableFromSuperAdmin() {
|
|
const keys = [...DEPLOY_SETTING_EDITABLE_KEYS];
|
|
if (!keys.length) return { templates: 0, settings: 0 };
|
|
|
|
const result = await this.prisma.$transaction(async (tx: any) => {
|
|
const templates = await tx.settingTemplate.deleteMany({
|
|
where: { key: { in: keys } },
|
|
});
|
|
const settings = await tx.setting.deleteMany({
|
|
where: { key: { in: keys } },
|
|
});
|
|
return { templates: templates.count, settings: settings.count };
|
|
});
|
|
|
|
if (result.templates || result.settings) {
|
|
this.replicaWatch.kick();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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[]> {
|
|
await this.ensureTemplatesSeeded();
|
|
const catalogKeys = [...DEPLOY_SETTING_CATALOG_KEYS].sort();
|
|
|
|
const [tenantRows, templates] = await Promise.all([
|
|
this.prisma.setting.findMany({
|
|
where: { tenantId, deletedAt: null, key: { in: catalogKeys } },
|
|
}),
|
|
this.prisma.settingTemplate.findMany({
|
|
where: { deletedAt: null, key: { in: catalogKeys } },
|
|
}),
|
|
]);
|
|
|
|
const tenantByKey = new Map(tenantRows.map((row: Setting) => [row.key, row]));
|
|
const templateByKey = new Map(templates.map((row: SettingTemplate) => [row.key, row]));
|
|
const seedByKey = new Map(
|
|
this.readSeedRows()
|
|
.filter((row) => row.key && DEPLOY_SETTING_CATALOG_KEYS.has(String(row.key)))
|
|
.map((row) => [String(row.key), row]),
|
|
);
|
|
|
|
return catalogKeys
|
|
.filter((key) => !isRuntimeProgramSettingKey(key))
|
|
.map((key) => {
|
|
const tenant = tenantByKey.get(key);
|
|
if (tenant) return this.toApiRow(tenant);
|
|
|
|
const tpl = templateByKey.get(key);
|
|
if (tpl) return this.toApiRow(tpl);
|
|
|
|
const seed = seedByKey.get(key);
|
|
return {
|
|
key,
|
|
value: seed?.value == null ? '' : String(seed.value),
|
|
title_fa: seed?.title_fa || key,
|
|
description: seed?.description || '',
|
|
type: seed?.type || '',
|
|
type_data: '',
|
|
is_active: seed?.is_active ?? 1,
|
|
can_edit: 0,
|
|
is_exist: 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
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,
|
|
key: { in: [...DEPLOY_SETTING_CATALOG_KEYS] },
|
|
},
|
|
});
|
|
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;
|
|
}
|
|
}
|