Files
api/src/get-user/get-user.service.ts
T

300 lines
9.3 KiB
TypeScript
Raw Normal View History

2026-07-23 10:25:32 +03:30
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
type UiSetting = { key: string; value: string };
2026-07-23 10:25:32 +03:30
@Injectable()
export class GetUserService {
constructor(private readonly prisma: PrismaService) {}
async resolveByHost(hostHeader?: string, domainQuery?: string) {
const host = this.normalizeHost(domainQuery || hostHeader || '');
if (!host) {
throw new NotFoundException('domain required');
}
2026-07-23 19:41:21 +03:30
const tenants = await this.prisma.tenant.findMany({
where: { isActive: true },
2026-07-23 10:25:32 +03:30
});
const tenant = this.findTenantForHost(tenants, host);
2026-07-23 10:25:32 +03:30
if (!tenant) {
throw new NotFoundException('tenant not found for domain');
}
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
2026-07-23 10:25:32 +03:30
const branding = {
id: tenant.id,
2026-07-23 19:41:21 +03:30
name: tenant.nameEn || tenant.slug,
2026-07-23 10:25:32 +03:30
name_fa: tenant.nameFa,
name_en: tenant.nameEn,
avatar: tenant.avatarUrl,
color,
secondary_color: secondary,
background_color: background,
2026-07-23 10:25:32 +03:30
node_server: tenant.nodeServer,
node_ws: tenant.nodeWs,
app_url: tenant.appUrl,
admin_url: tenant.adminUrl,
product: tenant.product,
theme: themeMode,
mode: themeMode,
/** 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,
2026-07-23 10:25:32 +03:30
};
return {
...branding,
user: { ...branding },
};
}
/**
* 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 = String(raw || '').trim();
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') return;
if (navigation.type === 'sidebar+bottom') navigation.type = 'both';
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;
});
};
navigation.sidebar = mapItems(navigation.sidebar);
navigation.bottom = mapItems(navigation.bottom);
if (navigation.sidebarReferral?.route === 'products') {
navigation.sidebarReferral.route = 'buyProduct';
}
}
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;
}
/**
* اول دامنهٔ اصلی (Tenant.domain)، بعد aliases داخل domains[].
* اگر چند نسخه یک دامنه را داشته باشند، نسخهٔ primary برنده است.
*/
private findTenantForHost<
T extends {
domain: string;
domains: unknown;
},
>(tenants: T[], host: string): T | undefined {
const normalizedHost = this.normalizeHost(host);
const hostNoWww = normalizedHost.replace(/^www\./, '');
const primary = tenants.find((item) => {
const d = this.normalizeHost(item.domain);
return d === normalizedHost || d.replace(/^www\./, '') === hostNoWww;
});
if (primary) return primary;
return tenants.find((item) => this.matchesAliasHost(item, normalizedHost));
}
private matchesAliasHost(
tenant: {
domain: string;
domains: unknown;
},
host: string,
) {
const hostNoWww = host.replace(/^www\./, '');
for (const value of this.readDomains(tenant.domains)) {
const normalized = this.normalizeHost(value);
if (!normalized) continue;
if (normalized === host || normalized.replace(/^www\./, '') === hostNoWww) {
return true;
}
}
return false;
}
2026-07-23 19:41:21 +03:30
private matchesHost(
tenant: {
domain: string;
domains: unknown;
},
host: string,
) {
return Boolean(this.findTenantForHost([tenant], host));
2026-07-23 19:41:21 +03:30
}
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 [];
}
2026-07-23 10:25:32 +03:30
private normalizeHost(value: string) {
return value
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.split('/')[0]
.split(':')[0];
}
}