2026-08-06 16:58:19 +03:30
|
|
|
/**
|
|
|
|
|
* 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');
|
|
|
|
|
|
2026-08-06 17:17:56 +03:30
|
|
|
const byId = env('SMS_COPY_TO_TENANT_ID') || env('SMS_COPY_FROM_TENANT_ID');
|
|
|
|
|
// Prefer explicit ids when provided via dedicated envs in main().
|
|
|
|
|
|
2026-08-06 16:58:19 +03:30
|
|
|
const candidates = new Set<string>([host]);
|
|
|
|
|
if (host.startsWith('app.')) candidates.add(host.slice(4));
|
|
|
|
|
else candidates.add(`app.${host}`);
|
|
|
|
|
|
|
|
|
|
const tenants = await prisma.tenant.findMany({ where: { isActive: true } });
|
2026-08-06 17:17:56 +03:30
|
|
|
|
|
|
|
|
// 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) => {
|
2026-08-06 16:58:19 +03:30
|
|
|
for (const d of readDomains(t.domains)) {
|
2026-08-06 17:17:56 +03:30
|
|
|
if (candidates.has(normalizeHost(d))) return true;
|
2026-08-06 16:58:19 +03:30
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
});
|
2026-08-06 17:17:56 +03:30
|
|
|
if (hit) return hit;
|
2026-08-06 16:58:19 +03:30
|
|
|
|
2026-08-06 17:17:56 +03:30
|
|
|
// 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;
|
2026-08-06 16:58:19 +03:30
|
|
|
}
|
2026-08-06 17:17:56 +03:30
|
|
|
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);
|
2026-08-06 16:58:19 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
2026-08-06 17:17:56 +03:30
|
|
|
// 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,
|
|
|
|
|
});
|
2026-08-06 16:58:19 +03:30
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|