/** * Backfill SuperAdmin tenant appearance from live registries (by domain Origin) * and users.json fallback. Preserves named CSS modes (e.g. limangold). * * Usage: * npm run prisma:backfill-appearance */ import * as fs from 'fs' import * as path from 'path' import { Prisma, PrismaClient } from '@prisma/client' import { buildHamkarUiFromBrand, buildKhanegiUiFromColors, pickRegistryPayload, resolveAvatarUrl, type RegistryUser, } from './lib/appearance-builders' const prisma = new PrismaClient() const REGISTRY_URLS = [ 'https://nerkh.gold/get_user', 'https://ah.aygingold.ir/get_user', 'https://tablo.talasevin.ir/get_user', 'https://abshode-service.montgit.ir/server/get_user', ] type JsonUser = { id: number name?: string name_fa?: string avatar?: string | null domains?: string | string[] | null color?: string | null secondary_color?: string | null background_color?: string | null mode?: string | null app_ui_config?: unknown deleted_at?: string | null node_server?: string | null } function normalizeHost(value: string) { return value .trim() .toLowerCase() .replace(/^https?:\/\//, '') .split('/')[0] .split(':')[0] } function parseDomains(raw: unknown): string[] { if (!raw) return [] if (Array.isArray(raw)) return raw.map(String).map(normalizeHost).filter(Boolean) if (typeof raw === 'string') { try { const parsed = JSON.parse(raw) if (Array.isArray(parsed)) return parsed.map(String).map(normalizeHost).filter(Boolean) } catch { return [] } } return [] } function readTenantHosts(tenant: { domain: string; domains: unknown; appUrl?: string | null }) { const hosts = new Set() hosts.add(normalizeHost(tenant.domain)) for (const d of parseDomains(tenant.domains)) hosts.add(d) if (tenant.appUrl) hosts.add(normalizeHost(tenant.appUrl)) return [...hosts].filter(Boolean) } function loadUsersJson(): JsonUser[] { const file = path.join(__dirname, 'data', 'users.json') if (!fs.existsSync(file)) return [] try { return JSON.parse(fs.readFileSync(file, 'utf8')) as JsonUser[] } catch { return [] } } function findJsonUser(tenant: { domain: string; domains: unknown; notes?: string | null; slug: string }, rows: JsonUser[]) { const hosts = new Set(readTenantHosts(tenant)) const external = tenant.notes?.match(/externalId:(\d+)/)?.[1] for (const row of rows) { if (row.deleted_at) continue if (external && String(row.id) === external) return row const rowHosts = parseDomains(row.domains) if (rowHosts.some((h) => hosts.has(h))) return row if (row.name && normalizeHost(row.name) === normalizeHost(tenant.slug)) return row } return null } async function fetchRegistryByOrigin(domain: string): Promise<{ data: RegistryUser; base: string } | null> { const origin = `https://${domain}` for (const base of REGISTRY_URLS) { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 8000) try { const res = await fetch(base, { headers: { Accept: 'application/json', Origin: origin, Referer: `${origin}/`, }, signal: controller.signal, }) if (!res.ok) continue const json = await res.json() const data = pickRegistryPayload(json) // Reject empty / unrelated responses: must mention domain in node_server/domains-ish or have name const nodeHost = normalizeHost(String(data.node_server || '')) const related = nodeHost.includes(domain.replace(/^www\./, '')) || domain.includes(normalizeHost(String(data.name || ''))) || Boolean(data.avatar || data.color || data.secondary_color || data.app_ui_config) if (!related && !data.name) continue return { data, base } } catch { /* next */ } finally { clearTimeout(timer) } } return null } function jsonToRegistry(row: JsonUser): RegistryUser { return { avatar: row.avatar, color: row.color, secondary_color: row.secondary_color, background_color: row.background_color, name: row.name, name_fa: row.name_fa, mode: row.mode, theme: row.mode, app_ui_config: row.app_ui_config, } } async function main() { const jsonRows = loadUsersJson() const tenants = await prisma.tenant.findMany({ where: { isActive: true }, orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], }) const summary = { total: tenants.length, updated: 0, fromLive: 0, fromJson: 0, skipped: 0, hamkar: 0, khanegi: 0, namedModes: 0, } for (const tenant of tenants) { const hosts = readTenantHosts(tenant) let hit: { data: RegistryUser; base: string; source: 'live' | 'json' } | null = null for (const host of hosts) { const live = await fetchRegistryByOrigin(host) if (live) { hit = { ...live, source: 'live' } break } } if (!hit) { const row = findJsonUser(tenant, jsonRows) if (row) { hit = { data: jsonToRegistry(row), base: 'https://nerkh.gold/get_user', source: 'json', } } } if (!hit) { summary.skipped += 1 console.warn(`SKIP ${tenant.product} ${tenant.domain}`) continue } const { data, base, source } = hit if (source === 'live') summary.fromLive += 1 else summary.fromJson += 1 // Prefer live/json values only — never keep invented SA defaults like #1d4ed8 const primary = data.color ?? null const secondary = data.secondary_color ?? null const background = data.background_color ?? null const avatar = resolveAvatarUrl(data.avatar, base) || tenant.avatarUrl const registryMode = String(data.mode || data.theme || '').trim() || null if (registryMode && registryMode !== 'dynamicMode') summary.namedModes += 1 const existingUi = (tenant.appUiConfig as Record | null) || null let appUiConfig: Prisma.InputJsonValue if (tenant.product === 'hamkar') { const managed = existingUi?.kind === 'hamkar' && existingUi?.settingsManaged === true const built = buildHamkarUiFromBrand({ primaryColor: primary, secondaryColor: secondary, backgroundColor: background, settingsManaged: managed, registryMode: registryMode || existingUi?.registryMode || 'dynamicMode', settings: managed ? existingUi.settings : undefined, }) appUiConfig = built as unknown as Prisma.InputJsonValue summary.hamkar += 1 } else { const built = buildKhanegiUiFromColors({ primary: primary || tenant.primaryColor, secondary: secondary || tenant.secondaryColor, background: background || tenant.backgroundColor, existing: data.app_ui_config || (existingUi?.schemaId === 'ui-config' ? existingUi : null), }) appUiConfig = { ...(built as object), registryMode: registryMode || existingUi?.registryMode || null, } as unknown as Prisma.InputJsonValue summary.khanegi += 1 } const notes = [ tenant.notes || '', `appearance:backfill-${source}`, registryMode ? `registryMode:${registryMode}` : '', ] .filter(Boolean) .join(' | ') await prisma.tenant.update({ where: { id: tenant.id }, data: { avatarUrl: avatar, primaryColor: primary, secondaryColor: secondary, backgroundColor: background, appUiConfig, notes, }, }) summary.updated += 1 console.log( `OK ${tenant.product} ${tenant.domain} ← ${source}${registryMode ? ` mode=${registryMode}` : ''} color=${primary || '-'}`, ) } console.log('\nSummary:', summary) } main() .catch((error) => { console.error(error) process.exitCode = 1 }) .finally(async () => { await prisma.$disconnect() })