/** * Copy SMS wallet (+ optional history) from one SuperAdmin tenant to another. * * Example (on SuperAdmin server): * SMS_COPY_FROM_DOMAIN=k.montzar.ir \ * SMS_COPY_TO_DOMAIN=app.aygingold.ir \ * SMS_COPY_HISTORY=1 \ * SMS_COPY_DRY_RUN=1 \ * npx ts-node --transpile-only prisma/copy-sms-between-tenants.ts */ import { PrismaClient, SmsLedgerType } from '@prisma/client'; const prisma = new PrismaClient(); function env(name: string, fallback = '') { return String(process.env[name] ?? fallback).trim(); } function normalizeHost(value?: string | null) { if (!value) return ''; return String(value) .trim() .toLowerCase() .replace(/^https?:\/\//, '') .split('/')[0] .split(':')[0]; } function readDomains(raw: unknown): string[] { if (!raw) return []; if (Array.isArray(raw)) return raw.map(String); if (typeof raw === 'string') { try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(String) : []; } catch { return []; } } return []; } async function findTenantByDomain(domainRaw: string) { const host = normalizeHost(domainRaw); if (!host) throw new Error('domain is empty'); const byId = env('SMS_COPY_TO_TENANT_ID') || env('SMS_COPY_FROM_TENANT_ID'); // Prefer explicit ids when provided via dedicated envs in main(). const candidates = new Set([host]); if (host.startsWith('app.')) candidates.add(host.slice(4)); else candidates.add(`app.${host}`); const tenants = await prisma.tenant.findMany({ where: { isActive: true } }); // 1) Exact primary domain let hit = tenants.find((t) => candidates.has(normalizeHost(t.domain))); if (hit) return hit; // 2) Exact match in domains[] only (no nodeServer fuzzy) hit = tenants.find((t) => { for (const d of readDomains(t.domains)) { if (candidates.has(normalizeHost(d))) return true; } return false; }); if (hit) return hit; // 3) Exact appUrl host hit = tenants.find((t) => candidates.has(normalizeHost(t.appUrl))); if (hit) return hit; const sample = tenants .filter((t) => /ayg|montzar|آیگ|ایگ/i.test(`${t.domain} ${t.nameFa} ${t.nodeServer} ${t.appUrl}`)) .map((t) => ({ id: t.id, domain: t.domain, nameFa: t.nameFa, nodeServer: t.nodeServer, appUrl: t.appUrl, domains: t.domains, })); console.error('Nearby tenants:', sample); throw new Error(`No active tenant matched domain exactly: ${host}`); } async function findTenant(opts: { domain?: string; tenantId?: string; nameFa?: string; }) { if (opts.tenantId) { const t = await prisma.tenant.findUnique({ where: { id: opts.tenantId } }); if (!t) throw new Error(`Tenant id not found: ${opts.tenantId}`); return t; } if (opts.nameFa) { const name = opts.nameFa.trim(); const rows = await prisma.tenant.findMany({ where: { isActive: true, nameFa: { contains: name } }, }); if (rows.length === 1) return rows[0]; if (rows.length === 0) throw new Error(`No tenant nameFa contains: ${name}`); console.error( 'Multiple name matches:', rows.map((t) => ({ id: t.id, nameFa: t.nameFa, domain: t.domain })), ); throw new Error(`Ambiguous nameFa: ${name} (use SMS_COPY_TO_TENANT_ID)`); } if (!opts.domain) throw new Error('domain/tenantId/nameFa required'); return findTenantByDomain(opts.domain); } async function main() { const fromDomain = env('SMS_COPY_FROM_DOMAIN', 'k.montzar.ir'); const toDomain = env('SMS_COPY_TO_DOMAIN', 'app.aygingold.ir'); const dryRun = env('SMS_COPY_DRY_RUN') === '1'; const withHistory = env('SMS_COPY_HISTORY', '1') !== '0'; const addDomainAlias = env('SMS_COPY_ADD_ALIAS', '1') !== '0'; // If a previous run wrongly attached app.aygingold.ir onto k.montzar.ir, strip it first // unless source and target are intentionally the same. const stripWrongAlias = env('SMS_COPY_STRIP_ALIAS_FROM', 'k.montzar.ir'); if (stripWrongAlias && normalizeHost(stripWrongAlias) !== normalizeHost(toDomain)) { const maybe = await prisma.tenant.findFirst({ where: { domain: normalizeHost(stripWrongAlias) }, }); if (maybe) { const hostTo = normalizeHost(toDomain); const current = readDomains(maybe.domains).map(normalizeHost).filter(Boolean); if (current.includes(hostTo)) { const next = current.filter((d) => d !== hostTo); console.log(`Stripping alias ${hostTo} from ${maybe.domain} domains:`, current, '=>', next); if (!dryRun) { await prisma.tenant.update({ where: { id: maybe.id }, data: { domains: next }, }); } } } } const fromTenant = await findTenant({ tenantId: env('SMS_COPY_FROM_TENANT_ID') || undefined, domain: fromDomain, }); const toTenant = await findTenant({ tenantId: env('SMS_COPY_TO_TENANT_ID') || undefined, nameFa: env('SMS_COPY_TO_NAME_FA') || undefined, domain: env('SMS_COPY_TO_TENANT_ID') || env('SMS_COPY_TO_NAME_FA') ? undefined : toDomain, }); const fromAccount = await prisma.smsAccount.findUnique({ where: { tenantId: fromTenant.id }, }); if (!fromAccount) { throw new Error(`Source tenant has no SMS account: ${fromTenant.domain}`); } const toAccountExisting = await prisma.smsAccount.findUnique({ where: { tenantId: toTenant.id }, }); console.log( JSON.stringify( { dryRun, withHistory, addDomainAlias, from: { id: fromTenant.id, nameFa: fromTenant.nameFa, domain: fromTenant.domain, balanceRials: fromAccount.balanceRials, }, to: { id: toTenant.id, nameFa: toTenant.nameFa, domain: toTenant.domain, existingBalanceRials: toAccountExisting?.balanceRials ?? null, }, }, null, 2, ), ); if (fromTenant.id === toTenant.id) { console.log('Source and target are the same tenant — nothing to copy.'); if (addDomainAlias) { const host = normalizeHost(toDomain); const current = readDomains(fromTenant.domains).map(normalizeHost).filter(Boolean); if (!current.includes(host) && normalizeHost(fromTenant.domain) !== host) { const next = [...new Set([...current, host])]; if (!dryRun) { await prisma.tenant.update({ where: { id: fromTenant.id }, data: { domains: next }, }); } console.log(`Alias domain added to tenant domains: ${host}`); } } return; } if (dryRun) { console.log('DRY RUN: no writes.'); return; } const accountPayload = { balanceRials: fromAccount.balanceRials, pricePerPartRials: fromAccount.pricePerPartRials, isBlocked: fromAccount.isBlocked, alertCriticalRials: fromAccount.alertCriticalRials, alertWarningRials: fromAccount.alertWarningRials, gatewayProvider: fromAccount.gatewayProvider, melipayamakUser: fromAccount.melipayamakUser, melipayamakPassword: fromAccount.melipayamakPassword, melipayamakFrom: fromAccount.melipayamakFrom, panelUsername: fromAccount.panelUsername, panelPassword: fromAccount.panelPassword, panelFrom: fromAccount.panelFrom, chargeCard: fromAccount.chargeCard, chargeIban: fromAccount.chargeIban, chargeHolder: fromAccount.chargeHolder, chargeInstruction: fromAccount.chargeInstruction, extraSettings: fromAccount.extraSettings ?? undefined, }; const toAccount = await prisma.smsAccount.upsert({ where: { tenantId: toTenant.id }, create: { tenantId: toTenant.id, ...accountPayload }, update: accountPayload, }); let ledgerCount = 0; let sendLogCount = 0; if (withHistory) { await prisma.smsSendLog.deleteMany({ where: { smsAccountId: toAccount.id } }); await prisma.smsLedger.deleteMany({ where: { smsAccountId: toAccount.id } }); const ledgers = await prisma.smsLedger.findMany({ where: { smsAccountId: fromAccount.id }, orderBy: { createdAt: 'asc' }, }); const logs = await prisma.smsSendLog.findMany({ where: { smsAccountId: fromAccount.id }, orderBy: { createdAt: 'asc' }, }); const chunk = 200; for (let i = 0; i < ledgers.length; i += chunk) { const slice = ledgers.slice(i, i + chunk); await prisma.smsLedger.createMany({ data: slice.map((row) => ({ smsAccountId: toAccount.id, type: row.type as SmsLedgerType, amountRials: row.amountRials, balanceAfterRials: row.balanceAfterRials, parts: row.parts, note: row.note, createdById: row.createdById, createdAt: row.createdAt, })), }); } for (let i = 0; i < logs.length; i += chunk) { const slice = logs.slice(i, i + chunk); await prisma.smsSendLog.createMany({ data: slice.map((row) => ({ smsAccountId: toAccount.id, receiver: row.receiver, body: row.body, parts: row.parts, pricePerPartRials: row.pricePerPartRials, totalAmountRials: row.totalAmountRials, provider: row.provider, source: row.source, isAlert: row.isAlert, createdAt: row.createdAt, })), }); } ledgerCount = ledgers.length; sendLogCount = logs.length; } if (addDomainAlias) { // Ensure target tenant also recognizes k.montzar.ir alias if useful const aliases = [normalizeHost(fromDomain), normalizeHost(toDomain)].filter(Boolean); const current = readDomains(toTenant.domains).map(normalizeHost).filter(Boolean); const next = [...new Set([...current, ...aliases.filter((d) => d !== normalizeHost(toTenant.domain))])]; await prisma.tenant.update({ where: { id: toTenant.id }, data: { domains: next }, }); } await prisma.auditLog.create({ data: { action: 'sms.copy.between_tenants', entityType: 'sms_account', entityId: toAccount.id, tenantId: toTenant.id, after: { fromTenantId: fromTenant.id, fromDomain: fromTenant.domain, toDomain: toTenant.domain, balanceRials: accountPayload.balanceRials, ledgerCount, sendLogCount, }, }, }); console.log('OK: SMS account copied.', { toSmsAccountId: toAccount.id, balanceRials: accountPayload.balanceRials, ledgerCount, sendLogCount, }); } main() .catch((err) => { console.error(err); process.exitCode = 1; }) .finally(async () => { await prisma.$disconnect(); });