import * as fs from 'fs'; import * as path from 'path'; import { Prisma, PrismaClient, ProductType } from '@prisma/client'; const prisma = new PrismaClient(); type SourceUser = { id: number; name: string; name_fa: string; number?: string | null; avatar?: string | null; domains?: string | string[] | null; node_server?: string | null; node_ws?: string | null; color?: string | null; secondary_color?: string | null; background_color?: string | null; domain?: string | null; version?: string | null; app_ui_config?: unknown; deleted_at?: string | null; mode?: string | null; }; /** Manual product corrections from SuperAdmin review */ const FORCE_KHANEGI: Array<{ domain?: string; name?: string; externalId?: number }> = [ { domain: 'abshodehtabatabaei.ir' }, { domain: 'boursemes.com' }, { domain: 'abaeigold.ir' }, { domain: 'niazitala.ir' }, { domain: 'garoosigholaktala.ir' }, // Khanegi newTheme (userTheme front) — NOT modular, NOT Hamkar. // Keep uiVariant=newTheme in DB; see prisma/khanegi-variant-locks.ts { domain: 'new.gholaktala.ir' }, { domain: 'kh.kashkolgold.ir' }, { domain: 'goldsun.zaarbox.ir' }, { domain: 'goldaliii.com' }, ]; /** Manual Hamkar locks — never put Khanegi domains here. */ const FORCE_HAMKAR: Array<{ domain?: string; name?: string; externalId?: number }> = []; /** Canonical public domains after migrations */ const DOMAIN_OVERRIDES: Record = { '24gold.app': { domain: '24gold.ir', appUrl: 'https://24gold.ir', nodeServer: 'https://24gold.ir/server', }, 'liman.gold': { domain: 'limangold.ir', appUrl: 'https://limangold.ir', nodeServer: 'https://limangold.ir/server', }, 'hamkar2.vahid.gold': { domain: 'vahid.gold', appUrl: 'https://vahid.gold', nodeServer: 'https://vahid.gold/server', }, }; function parseDomains(raw: SourceUser['domains']): string[] { if (!raw) return []; try { const value = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!Array.isArray(value)) return []; return value .map((item) => String(item).trim().toLowerCase()) .filter(Boolean) .map((item) => item.replace(/^https?:\/\//, '').split('/')[0]); } catch { return []; } } function isIp(host: string) { return /^\d{1,3}(\.\d{1,3}){3}$/.test(host); } function hostFromUrl(url?: string | null) { if (!url) return ''; return url .trim() .toLowerCase() .replace(/^https?:\/\//, '') .split('/')[0] .split(':')[0]; } function classifyProduct(row: SourceUser, domains: string[]): { product: Exclude; confident: boolean; reason: string; } { const forcedHamkar = FORCE_HAMKAR.find((item) => { if (item.externalId && item.externalId === row.id) return true; if (item.domain && domains.includes(item.domain)) return true; return false; }); if (forcedHamkar) { return { product: 'hamkar', confident: true, reason: `manual override hamkar (${forcedHamkar.domain || forcedHamkar.externalId})`, }; } const forced = FORCE_KHANEGI.find((item) => { if (item.externalId && item.externalId === row.id) return true; if (item.domain && domains.includes(item.domain)) return true; return false; }); if (forced) { return { product: 'khanegi', confident: true, reason: `manual override khanegi (${forced.domain || forced.externalId})`, }; } if (domains.some((d) => d.startsWith('app.'))) { return { product: 'khanegi', confident: true, reason: 'domain starts with app.' }; } if (domains.some((d) => d.startsWith('khanegi.'))) { return { product: 'khanegi', confident: true, reason: 'domain starts with khanegi.' }; } if (domains.some((d) => d.startsWith('hamkar.'))) { return { product: 'hamkar', confident: true, reason: 'domain starts with hamkar.' }; } const nodeHost = hostFromUrl(row.node_server); if (nodeHost.startsWith('app.')) { return { product: 'khanegi', confident: true, reason: 'node_server host starts with app.', }; } // Fallback guess — flagged for manual review if (row.app_ui_config) { return { product: 'khanegi', confident: false, reason: 'has app_ui_config but no app./hamkar. domain', }; } return { product: 'hamkar', confident: false, reason: 'no app./hamkar. domain — defaulted to hamkar', }; } function pickDomain(product: ProductType, domains: string[], row: SourceUser) { for (const domain of domains) { if (DOMAIN_OVERRIDES[domain]) return DOMAIN_OVERRIDES[domain].domain; } const preferredPrefix = product === 'khanegi' ? 'app.' : 'hamkar.'; const preferred = domains.find((d) => d.startsWith(preferredPrefix)); if (preferred) return preferred; const nonIp = domains.find((d) => !isIp(d)); if (nonIp) return nonIp; const fromNode = hostFromUrl(row.node_server); if (fromNode && !isIp(fromNode)) return fromNode; if (row.domain) return String(row.domain).toLowerCase(); return domains[0] || `${slugify(row.name)}.local`; } function slugify(value: string) { return value .trim() .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9._-]/g, '') .replace(/\.+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') || 'tenant'; } function avatarUrl(raw?: string | null) { if (!raw) return null; if (/^https?:\/\//i.test(raw)) return raw; return null; } function asUiConfig(value: unknown): Prisma.InputJsonValue | undefined { if (value == null) return undefined; if (typeof value === 'string') { try { return JSON.parse(value) as Prisma.InputJsonValue; } catch { return undefined; } } if (typeof value === 'object') return value as Prisma.InputJsonValue; return undefined; } async function uniqueSlug(base: string, product: ProductType, externalId: number) { const candidates = [ base, `${base}-${product}`, `${base}-${product}-${externalId}`, `${base}-${externalId}`, ]; for (const slug of candidates) { const existing = await prisma.tenant.findUnique({ where: { slug } }); if (!existing) return slug; // allow overwrite of previously imported same external id via notes marker if (existing.notes?.includes(`externalId:${externalId}`)) return slug; } return `${base}-${product}-${externalId}-${Date.now()}`; } async function uniqueDomain(preferred: string, externalId: number) { const candidates = [ preferred, `${productSafeHost(preferred)}`, `id${externalId}.${preferred}`, `ext-${externalId}.${preferred}`, ]; for (const domain of candidates) { const existing = await prisma.tenant.findUnique({ where: { domain } }); if (!existing) return domain; if (existing.notes?.includes(`externalId:${externalId}`)) return domain; } return `ext-${externalId}-${Date.now()}.local`; } function productSafeHost(host: string) { return host; } async function main() { const file = path.join(__dirname, 'data', 'users.json'); if (!fs.existsSync(file)) { throw new Error(`File not found: ${file}`); } const rows = JSON.parse(fs.readFileSync(file, 'utf8')) as SourceUser[]; const summary = { total: rows.length, imported: 0, skippedDeleted: 0, hamkar: 0, khanegi: 0, uncertain: 0, withUiConfig: 0, }; for (const row of rows) { if (row.deleted_at) { summary.skippedDeleted += 1; continue; } const domains = parseDomains(row.domains); const { product, confident, reason } = classifyProduct(row, domains); const baseSlug = slugify(row.name); const slug = await uniqueSlug(baseSlug, product, row.id); const preferredDomain = pickDomain(product, domains, row); const domain = await uniqueDomain(preferredDomain, row.id); const ui = asUiConfig(row.app_ui_config); const domainOverride = DOMAIN_OVERRIDES[domain] || domains.map((d) => DOMAIN_OVERRIDES[d]).find(Boolean); const notes = [ `externalId:${row.id}`, `import:users.json`, `classify:${reason}`, confident ? 'classifyConfidence:high' : 'classifyConfidence:low', row.number ? `phone:${row.number}` : null, row.version ? `version:${row.version}` : null, row.mode ? `mode:${row.mode}` : null, ] .filter(Boolean) .join(' | '); const data = { product, slug, nameFa: (row.name_fa || row.name).replace(/\s+/g, ' ').trim(), nameEn: row.name, domain: domainOverride?.domain || domain, domains: domains as unknown as Prisma.InputJsonValue, avatarUrl: avatarUrl(row.avatar), primaryColor: row.color || (product === 'hamkar' ? '#20c997' : '#ffb84d'), secondaryColor: row.secondary_color || null, backgroundColor: row.background_color || '#ffffff', nodeServer: domainOverride?.nodeServer || row.node_server || `https://${domain}/server`, nodeWs: row.node_ws || null, appUrl: domainOverride?.appUrl || domains.find((d) => !isIp(d)) || domain, adminUrl: null as string | null, appUiConfig: ui, appVersion: String(row.version || '1').trim() || '1', isActive: true, notes, }; const existingByNotes = await prisma.tenant.findFirst({ where: { notes: { contains: `externalId:${row.id}` } }, }); if (existingByNotes) { await prisma.tenant.update({ where: { id: existingByNotes.id }, data, }); } else { // Prefer update by slug if this is a known seed like bakhtargold const bySlug = await prisma.tenant.findUnique({ where: { slug } }); if (bySlug) { await prisma.tenant.update({ where: { id: bySlug.id }, data }); } else { await prisma.tenant.create({ data }); } } summary.imported += 1; summary[product] += 1; if (!confident) summary.uncertain += 1; if (ui) summary.withUiConfig += 1; } console.log('Import finished:', summary); } main() .catch((error) => { console.error(error); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });