Files

277 lines
8.0 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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<string>()
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<string, any> | null) || null
let appUiConfig: Prisma.InputJsonValue
if (tenant.product === 'hamkar') {
const managed = existingUi?.kind === 'hamkar' && existingUi?.settingsManaged === true
const brandManaged = existingUi?.kind === 'hamkar' && existingUi?.brandManaged === true
const built = buildHamkarUiFromBrand({
primaryColor: primary,
secondaryColor: secondary,
backgroundColor: background,
menuIconColor: existingUi?.brand?.menuIconColor ?? null,
menuIconActiveColor: existingUi?.brand?.menuIconActiveColor ?? null,
settingsManaged: managed,
brandManaged,
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()
})