Add production-ready SuperAdmin API features for tenants, SMS, servers, and plugins.
Includes mobile auth, get_user/get_version, SMS wallet parity endpoints, appearance backfill, and related Prisma migrations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 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 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()
|
||||
})
|
||||
Reference in New Issue
Block a user