From 7ebee7fa4e688ae898f00311bff0a3b460e43a9c Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 6 Aug 2026 16:58:19 +0330 Subject: [PATCH] Add script to copy SMS wallet between SuperAdmin tenants. Co-authored-by: Cursor --- package.json | 3 +- prisma/copy-sms-between-tenants.ts | 275 +++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 prisma/copy-sms-between-tenants.ts diff --git a/package.json b/package.json index 58b6d9c..7898718 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "prisma:seed-hamkar-defaults": "ts-node --transpile-only prisma/seed-hamkar-default-snapshots.ts", "prisma:seed-khanegi-defaults": "ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts", "prisma:ensure-pilot-sms-tokens": "ts-node --transpile-only prisma/ensure-pilot-sms-tokens.ts", - "prisma:import-sms-from-product": "ts-node --transpile-only prisma/import-sms-from-product.ts" + "prisma:import-sms-from-product": "ts-node --transpile-only prisma/import-sms-from-product.ts", + "prisma:copy-sms-between-tenants": "ts-node --transpile-only prisma/copy-sms-between-tenants.ts" }, "prisma": { "seed": "ts-node --transpile-only prisma/seed.ts" diff --git a/prisma/copy-sms-between-tenants.ts b/prisma/copy-sms-between-tenants.ts new file mode 100644 index 0000000..3b85a55 --- /dev/null +++ b/prisma/copy-sms-between-tenants.ts @@ -0,0 +1,275 @@ +/** + * 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 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 } }); + const hit = tenants.find((t) => { + const set = new Set(); + const primary = normalizeHost(t.domain); + if (primary) set.add(primary); + for (const d of readDomains(t.domains)) { + const n = normalizeHost(d); + if (n) set.add(n); + } + for (const c of candidates) { + if (set.has(c)) return true; + } + const node = normalizeHost(t.nodeServer); + for (const c of candidates) { + if (node.includes(c)) return true; + } + const appUrl = normalizeHost(t.appUrl); + for (const c of candidates) { + if (appUrl === c) return true; + } + return false; + }); + + if (!hit) { + const sample = tenants + .filter((t) => /ayg|montzar|آیگ|ایگ/i.test(`${t.domain} ${t.nameFa} ${t.nodeServer}`)) + .map((t) => ({ id: t.id, domain: t.domain, nameFa: t.nameFa, nodeServer: t.nodeServer })); + console.error('Nearby tenants:', sample); + throw new Error(`No active tenant matched domain: ${host}`); + } + return hit; +} + +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'; + + const fromTenant = await findTenantByDomain(fromDomain); + const toTenant = await findTenantByDomain(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(); + });