390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
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<string, any> | 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<boolean> {
|
|
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<SubscriptionAlerts> {
|
|
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<string, any> | 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<string, any> | null) {
|
|
if (!ui || typeof ui !== 'object') return ui;
|
|
const next = JSON.parse(JSON.stringify(ui)) as Record<string, any>;
|
|
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<string, any>): 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<T extends { domain: string; domains: unknown }>(
|
|
tenants: T[],
|
|
host: string,
|
|
): T | undefined {
|
|
return matchTenantForHost(tenants, host);
|
|
}
|
|
}
|