Expose brand_chrome so apps apply header/menu/body colors only after SuperAdmin color save. Co-authored-by: Cursor <cursoragent@cursor.com>
275 lines
7.9 KiB
TypeScript
275 lines
7.9 KiB
TypeScript
/**
|
|
* 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,
|
|
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()
|
|
})
|