import { Injectable, NotFoundException } from '@nestjs/common'; import { iranDaysUntil } from '../common/iran-time.util'; import { PrismaService } from '../prisma/prisma.service'; import { applyKhanegiNavSample } from './khanegi-nav-sample'; import { rewriteLegacyUploadUrl } from '../uploads/uploads.util'; import { PLUGIN_STORE_SETTING_KEY } from '../program-settings/runtime-setting-keys'; import { findTenantForHost as matchTenantForHost, normalizeTenantHost, } from '../auth/tenant-host.util'; type UiSetting = { key: string; value: string }; type SubscriptionAlertStatus = 'expiring' | 'expired'; type SubscriptionAlertItem = { id: string; name: string; expires_at: string; days_left: number; status: SubscriptionAlertStatus; }; type SubscriptionAlerts = { servers: SubscriptionAlertItem[]; plugins: SubscriptionAlertItem[]; supports: SubscriptionAlertItem[]; }; @Injectable() export class GetUserService { constructor(private readonly prisma: PrismaService) {} async resolveByHost(hostHeader?: string, domainQuery?: string) { const host = normalizeTenantHost(domainQuery || hostHeader || ''); if (!host) { throw new NotFoundException('domain required'); } const tenants = await this.prisma.tenant.findMany({ where: { isActive: true }, }); const tenant = this.findTenantForHost(tenants, host); if (!tenant) { throw new NotFoundException('tenant not found for domain'); } const subscription_alerts = await this.buildSubscriptionAlerts(tenant.id); const ui = (tenant.appUiConfig as Record | null) || null; const isHamkarUi = tenant.product === 'hamkar' && ui?.kind === 'hamkar'; const hamkarBrand = isHamkarUi ? ui?.brand : null; const khanegiColors = ui?.app?.theme?.colors; const uiSettings = isHamkarUi && ui?.settingsManaged === true ? this.buildHamkarUiSettings(ui) : []; // Preserve named CSS modes from live registry (e.g. limangold / Montpar). // Fallback to notes marker if JSON field missing on older rows. const noteMode = tenant.notes?.match(/registryMode:([^\s|]+)/)?.[1] || null const registryMode = (typeof ui?.registryMode === 'string' && ui.registryMode.trim()) || noteMode || null const themeMode = registryMode && registryMode !== 'dynamicMode' ? registryMode : 'dynamicMode' const isKhanegi = tenant.product === 'khanegi'; const uiVariant = isKhanegi && tenant.uiVariant === 'newTheme' ? 'newTheme' : 'modular'; const color = hamkarBrand?.primaryColor || (uiVariant === 'newTheme' ? khanegiColors?.primary : null) || tenant.primaryColor || null const secondary = hamkarBrand?.secondaryColor || (uiVariant === 'newTheme' ? khanegiColors?.secondary : null) || tenant.secondaryColor || null const background = hamkarBrand?.backgroundColor || (uiVariant === 'newTheme' ? khanegiColors?.background : null) || tenant.backgroundColor || null const branding = { id: tenant.id, name: tenant.nameEn || tenant.slug, name_fa: tenant.nameFa, name_en: tenant.nameEn, avatar: rewriteLegacyUploadUrl(tenant.avatarUrl), color, secondary_color: secondary, background_color: background, node_server: tenant.nodeServer, node_ws: tenant.nodeWs, app_url: tenant.appUrl, admin_url: tenant.adminUrl, product: tenant.product, theme: themeMode, mode: themeMode, /** Stealth cold-boot gate — false looks like product server unreachable */ server_reachable: !Boolean(tenant.serverAccessBlocked), /** Khanegi front-end family for consumers */ ui_variant: isKhanegi ? uiVariant : undefined, app_ui_config: isKhanegi && uiVariant === 'newTheme' ? this.stripKhanegiSaMeta(this.normalizeKhanegiUiConfig(ui)) : undefined, /** Laravel-compatible overlay; Hamkar merges only when present */ ui_settings: uiSettings.length ? uiSettings : undefined, /** Admin expiry modals — near/expired server, plugin, support */ subscription_alerts, plugin_store_enabled: isKhanegi ? await this.isPluginStoreEnabled(tenant.id) : false, }; return { ...branding, user: { ...branding }, }; } private async isPluginStoreEnabled(tenantId: string): Promise { try { const row = await this.prisma.setting.findUnique({ where: { tenantId_key: { tenantId, key: PLUGIN_STORE_SETTING_KEY } }, select: { value: true }, }); return row?.value === 'true'; } catch { return false; } } private async buildSubscriptionAlerts(tenantId: string): Promise { const [servers, plugins, supports] = await Promise.all([ this.prisma.serverAccount.findMany({ where: { tenantId, isDedicated: false, expiresAt: { not: null }, }, select: { id: true, serverName: true, expiresAt: true, alertDaysBefore: true, }, }), this.prisma.pluginSubscription.findMany({ where: { tenantId, isActive: true, expiresAt: { not: null }, }, select: { id: true, name: true, expiresAt: true, alertDaysBefore: true, }, }), this.prisma.supportSubscription.findMany({ where: { tenantId, isActive: true, expiresAt: { not: null }, }, select: { id: true, name: true, expiresAt: true, alertDaysBefore: true, }, }), ]); return { servers: servers .map((row) => this.toAlertItem({ id: row.id, name: (row.serverName || '').trim() || 'سرور', expiresAt: row.expiresAt!, alertDaysBefore: row.alertDaysBefore, }), ) .filter((item): item is SubscriptionAlertItem => item != null), plugins: plugins .map((row) => this.toAlertItem({ id: row.id, name: (row.name || '').trim() || 'افزونه', expiresAt: row.expiresAt!, alertDaysBefore: row.alertDaysBefore, }), ) .filter((item): item is SubscriptionAlertItem => item != null), supports: supports .map((row) => this.toAlertItem({ id: row.id, name: (row.name || '').trim() || 'پشتیبانی', expiresAt: row.expiresAt!, alertDaysBefore: row.alertDaysBefore, }), ) .filter((item): item is SubscriptionAlertItem => item != null), }; } private toAlertItem(input: { id: string; name: string; expiresAt: Date; alertDaysBefore: number; }): SubscriptionAlertItem | null { const daysLeft = iranDaysUntil(input.expiresAt); const threshold = Math.max(0, Number(input.alertDaysBefore) || 0); let status: SubscriptionAlertStatus | null = null; if (daysLeft < 0) { status = 'expired'; } else if (daysLeft <= threshold) { status = 'expiring'; } if (!status) return null; return { id: input.id, name: input.name, expires_at: input.expiresAt.toISOString(), days_left: daysLeft, status, }; } /** * Web App Manifest برای PWA / ساخت APK — برندینگ همان get_user * GET /manifest.json?domain= */ async resolveManifestByHost(hostHeader?: string, domainQuery?: string) { const branding = await this.resolveByHost(hostHeader, domainQuery); const name = String(branding.name_fa || branding.name_en || branding.name || '').trim() || 'App'; const themeColor = String(branding.color || '').trim() || '#2563eb'; const backgroundColor = String(branding.background_color || '').trim() || '#ffffff'; const avatar = this.toAbsoluteIconUrl(branding.avatar); if (!avatar) { throw new NotFoundException('tenant avatar required for manifest'); } const iconType = this.guessImageMime(avatar); const iconSizes = ['1024x1024', '512x512', '192x192', '180x180'] as const; return { name, short_name: name, lang: 'fa', dir: 'rtl', start_url: '/', scope: '/', display: 'standalone', background_color: backgroundColor, theme_color: themeColor, description: name, icons: iconSizes.map((sizes) => ({ src: avatar, sizes, type: iconType, purpose: 'any', })), }; } private toAbsoluteIconUrl(raw: unknown): string { const value = rewriteLegacyUploadUrl(raw); if (!value) return ''; if (/^(https?:\/\/|data:)/i.test(value)) return value; if (value.startsWith('//')) return `https:${value}`; const publicBase = String(process.env.PUBLIC_API_URL || process.env.APP_URL || '') .trim() .replace(/\/$/, ''); if (publicBase && value.startsWith('/')) { return `${publicBase}${value}`; } if (publicBase) { return `${publicBase}/${value.replace(/^\/+/, '')}`; } return value.startsWith('/') ? value : `/${value}`; } private guessImageMime(url: string) { const path = url.split('?')[0].toLowerCase(); if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; if (path.endsWith('.webp')) return 'image/webp'; if (path.endsWith('.svg')) return 'image/svg+xml'; return 'image/png'; } private stripKhanegiSaMeta(ui: Record | null) { if (!ui || typeof ui !== 'object') return ui; const { defaultSnapshot: _snap, _backfill, ...rest } = ui; return rest; } /** * Backfillهای قدیمی sidebar+bottom / route=products / بدون header.variant * را با اسکیمای userTheme هم‌تراز می‌کند تا Zod رد نکند و fallback نشود. */ private normalizeKhanegiUiConfig(ui: Record | null) { if (!ui || typeof ui !== 'object') return ui; const next = JSON.parse(JSON.stringify(ui)) as Record; this.normalizeKhanegiAppNode(next.app); this.normalizeKhanegiAppNode(next.defaultSnapshot?.app); return next; } private normalizeKhanegiAppNode(app: any) { if (!app || typeof app !== 'object') return; if (app.startRoute === 'products') app.startRoute = 'buyProduct'; const header = app.header; if (header && typeof header === 'object' && header.variant !== 'default') { header.variant = 'default'; } const navigation = app.navigation; if (!navigation || typeof navigation !== 'object') { app.navigation = { type: 'both' }; } else if (navigation.type === 'sidebar+bottom') { navigation.type = 'both'; } const nav = app.navigation; const mapItems = (items: unknown) => { if (!Array.isArray(items)) return items; return items.map((item) => { if (!item || typeof item !== 'object') return item; if (item.route === 'products') return { ...item, route: 'buyProduct' }; return item; }); }; nav.sidebar = mapItems(nav.sidebar); nav.bottom = mapItems(nav.bottom); if (nav.sidebarReferral?.route === 'products') { nav.sidebarReferral.route = 'buyProduct'; } applyKhanegiNavSample(nav); } private buildHamkarUiSettings(ui: Record): UiSetting[] { const settings = ui?.settings; if (!settings || typeof settings !== 'object') return []; const out: UiSetting[] = []; for (const key of ['pricing_box_theme', 'price_font_size', 'orderUI'] as const) { const value = settings[key]; if (value == null || value === '') continue; out.push({ key, value: String(value) }); } return out; } /** * همان منطق HMAC ریموت: دامنهٔ اصلی، aliases، بعد admin/app/node URL. */ private findTenantForHost( tenants: T[], host: string, ): T | undefined { return matchTenantForHost(tenants, host); } }