Correct prior modular/Hamkar misclassification. Protect locked newTheme domains from bulk-modular brand overrides. Co-authored-by: Cursor <cursoragent@cursor.com>
346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
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<string, { domain: string; appUrl: string; nodeServer: string }> = {
|
|
'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: ProductType;
|
|
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();
|
|
});
|