Files
api/prisma/copy-sms-between-tenants.ts
T

276 lines
8.2 KiB
TypeScript
Raw Normal View History

/**
* 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<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 } });
const hit = tenants.find((t) => {
const set = new Set<string>();
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();
});