414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
/**
|
|
* Import real SMS wallet data from one Hamkar/Khanegi MySQL into SuperAdmin.
|
|
*
|
|
* Each sold version has its own MySQL (sms_wallets is single-row per server).
|
|
* Run once per version (SSH tunnel / on that DB host), matching SuperAdmin tenant by domain.
|
|
*
|
|
* Env:
|
|
* SMS_IMPORT_DOMAIN=montpar.ir # required (or SMS_IMPORT_TENANT_ID)
|
|
* SMS_IMPORT_PRODUCT=hamkar|khanegi # default: hamkar (charge-info key mapping)
|
|
* SMS_IMPORT_HISTORY=1 # also copy ledger + send logs (default 1)
|
|
* SMS_IMPORT_DRY_RUN=1 # print only, no writes
|
|
* SMS_IMPORT_REPLACE_HISTORY=1 # delete existing SA ledger/logs before copy (default 1 when HISTORY=1)
|
|
*
|
|
* DB (hamkar defaults from HAMKAR_DB_*; override with SMS_IMPORT_DB_*):
|
|
* SMS_IMPORT_DB_HOST / PORT / USER / PASSWORD / DATABASE
|
|
*
|
|
* Usage:
|
|
* cd apps/api
|
|
* npm run prisma:import-sms-from-product
|
|
*/
|
|
import * as mysql from 'mysql2/promise';
|
|
import { PrismaClient, SmsLedgerType } from '@prisma/client';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
type WalletRow = {
|
|
balance: number | string;
|
|
price_per_part: number | string;
|
|
is_blocked: number | boolean;
|
|
alert_days_sent?: number;
|
|
last_alert_date?: Date | string | null;
|
|
};
|
|
|
|
type SettingRow = { key: string; value: string | null };
|
|
|
|
type LedgerRow = {
|
|
type: string;
|
|
amount: number | string;
|
|
balance_after: number | string;
|
|
price_per_part: number | string | null;
|
|
parts: number | string | null;
|
|
note: string | null;
|
|
created_by: number | string | null;
|
|
created_at: Date | string;
|
|
};
|
|
|
|
type SendLogRow = {
|
|
receiver: string;
|
|
body: string;
|
|
parts: number | string;
|
|
price_per_part: number | string;
|
|
total_amount: number | string;
|
|
provider: string | null;
|
|
source: string | null;
|
|
is_alert: number | boolean;
|
|
created_at: Date | string;
|
|
};
|
|
|
|
function env(name: string, fallback = '') {
|
|
return String(process.env[name] ?? fallback).trim();
|
|
}
|
|
|
|
function asInt(value: unknown, fallback = 0) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
|
}
|
|
|
|
function asBool(value: unknown) {
|
|
return value === true || value === 1 || value === '1';
|
|
}
|
|
|
|
function asDate(value: Date | string) {
|
|
const d = value instanceof Date ? value : new Date(value);
|
|
return Number.isNaN(d.getTime()) ? new Date() : d;
|
|
}
|
|
|
|
function normalizeHost(value?: string | null) {
|
|
if (!value) return '';
|
|
return String(value)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^https?:\/\//, '')
|
|
.split('/')[0]
|
|
.split(':')[0];
|
|
}
|
|
|
|
function mapLedgerType(raw: string): SmsLedgerType {
|
|
const t = String(raw || '').toLowerCase();
|
|
if (t === 'charge') return 'charge';
|
|
if (t === 'alert_debit' || t === 'debit') return 'debit';
|
|
if (t === 'adjust') return 'adjust';
|
|
return 'debit';
|
|
}
|
|
|
|
async function tableExists(conn: mysql.Connection, database: string, table: string) {
|
|
const [rows] = await conn.query(
|
|
`SELECT 1 AS ok
|
|
FROM information_schema.tables
|
|
WHERE table_schema = ? AND table_name = ?
|
|
LIMIT 1`,
|
|
[database, table],
|
|
);
|
|
return Array.isArray(rows) && rows.length > 0;
|
|
}
|
|
|
|
async function findTenant(domainOrId: string) {
|
|
if (env('SMS_IMPORT_TENANT_ID')) {
|
|
const byId = await prisma.tenant.findUnique({
|
|
where: { id: env('SMS_IMPORT_TENANT_ID') },
|
|
});
|
|
if (!byId) throw new Error(`Tenant id not found: ${env('SMS_IMPORT_TENANT_ID')}`);
|
|
return byId;
|
|
}
|
|
|
|
const host = normalizeHost(domainOrId);
|
|
if (!host) throw new Error('SMS_IMPORT_DOMAIN or SMS_IMPORT_TENANT_ID is required');
|
|
|
|
const tenants = await prisma.tenant.findMany({
|
|
where: { isActive: true },
|
|
});
|
|
|
|
const hit = tenants.find((t) => {
|
|
const candidates = new Set<string>();
|
|
const primary = normalizeHost(t.domain);
|
|
if (primary) candidates.add(primary);
|
|
const extraRaw = t.domains as unknown;
|
|
const extra = Array.isArray(extraRaw)
|
|
? extraRaw
|
|
: typeof extraRaw === 'string'
|
|
? (() => {
|
|
try {
|
|
const parsed = JSON.parse(extraRaw);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
})()
|
|
: [];
|
|
for (const d of extra) {
|
|
const n = normalizeHost(String(d));
|
|
if (n) candidates.add(n);
|
|
}
|
|
return candidates.has(host);
|
|
});
|
|
|
|
if (!hit) throw new Error(`No SuperAdmin tenant matched domain: ${host}`);
|
|
return hit;
|
|
}
|
|
|
|
function chargeKeys(product: string) {
|
|
if (product === 'khanegi') {
|
|
return {
|
|
card: 'sms_charge_card_number',
|
|
iban: 'sms_charge_sheba',
|
|
holder: 'sms_charge_account_holder',
|
|
instruction: 'sms_charge_note',
|
|
};
|
|
}
|
|
return {
|
|
card: 'sms_charge_card',
|
|
iban: 'sms_charge_iban',
|
|
holder: 'sms_charge_account_holder',
|
|
instruction: 'sms_charge_instruction',
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const domain = env('SMS_IMPORT_DOMAIN');
|
|
const product = (env('SMS_IMPORT_PRODUCT', 'hamkar') || 'hamkar').toLowerCase();
|
|
const dryRun = env('SMS_IMPORT_DRY_RUN') === '1';
|
|
const withHistory = env('SMS_IMPORT_HISTORY', '1') !== '0';
|
|
const replaceHistory = env('SMS_IMPORT_REPLACE_HISTORY', withHistory ? '1' : '0') === '1';
|
|
|
|
const host =
|
|
env('SMS_IMPORT_DB_HOST') ||
|
|
(product === 'khanegi' ? env('KHANEGI_DB_HOST') : env('HAMKAR_DB_HOST')) ||
|
|
'127.0.0.1';
|
|
const port = Number(
|
|
env('SMS_IMPORT_DB_PORT') ||
|
|
(product === 'khanegi' ? env('KHANEGI_DB_PORT') : env('HAMKAR_DB_PORT')) ||
|
|
'3306',
|
|
);
|
|
const user =
|
|
env('SMS_IMPORT_DB_USER') ||
|
|
(product === 'khanegi' ? env('KHANEGI_DB_USER') : env('HAMKAR_DB_USER')) ||
|
|
'root';
|
|
const password =
|
|
env('SMS_IMPORT_DB_PASSWORD') ||
|
|
(product === 'khanegi' ? env('KHANEGI_DB_PASSWORD') : env('HAMKAR_DB_PASSWORD')) ||
|
|
'';
|
|
const database =
|
|
env('SMS_IMPORT_DB_DATABASE') ||
|
|
(product === 'khanegi' ? env('KHANEGI_DB_DATABASE') : env('HAMKAR_DB_DATABASE'));
|
|
|
|
if (!database) {
|
|
throw new Error('MySQL database name is required (SMS_IMPORT_DB_DATABASE / HAMKAR_DB_DATABASE)');
|
|
}
|
|
|
|
const tenant = await findTenant(domain);
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
dryRun,
|
|
product,
|
|
withHistory,
|
|
replaceHistory,
|
|
tenant: {
|
|
id: tenant.id,
|
|
nameFa: tenant.nameFa,
|
|
domain: tenant.domain,
|
|
product: tenant.product,
|
|
},
|
|
mysql: { host, port, user, database },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
const conn = await mysql.createConnection({
|
|
host,
|
|
port,
|
|
user,
|
|
password,
|
|
database,
|
|
dateStrings: false,
|
|
});
|
|
|
|
try {
|
|
if (!(await tableExists(conn, database, 'sms_wallets'))) {
|
|
console.log('SKIP: sms_wallets table missing — billing not installed on this version.');
|
|
return;
|
|
}
|
|
|
|
let smsPanel = '';
|
|
if (await tableExists(conn, database, 'settings')) {
|
|
const [panelRows] = await conn.query(
|
|
`SELECT \`value\` AS v FROM settings WHERE \`key\` = 'sms_panel' LIMIT 1`,
|
|
);
|
|
const first = Array.isArray(panelRows) ? (panelRows[0] as { v?: string }) : null;
|
|
smsPanel = String(first?.v ?? '').trim();
|
|
}
|
|
if (smsPanel === 'me') {
|
|
console.log('SKIP: sms_panel=me (customer own 0098 panel) — SuperAdmin billing not used.');
|
|
return;
|
|
}
|
|
|
|
const [walletRowsRaw] = await conn.query(
|
|
`SELECT balance, price_per_part, is_blocked, alert_days_sent, last_alert_date
|
|
FROM sms_wallets
|
|
ORDER BY id ASC
|
|
LIMIT 1`,
|
|
);
|
|
const walletRows = walletRowsRaw as WalletRow[];
|
|
if (!walletRows.length) {
|
|
console.log('SKIP: sms_wallets is empty — no real wallet yet.');
|
|
return;
|
|
}
|
|
|
|
const wallet = walletRows[0];
|
|
const keys = chargeKeys(product);
|
|
const settings = new Map<string, string>();
|
|
if (await tableExists(conn, database, 'settings')) {
|
|
const [settingRowsRaw] = await conn.query(
|
|
`SELECT \`key\`, \`value\` FROM settings WHERE \`key\` IN (?, ?, ?, ?)`,
|
|
[keys.card, keys.iban, keys.holder, keys.instruction],
|
|
);
|
|
for (const r of settingRowsRaw as SettingRow[]) {
|
|
settings.set(r.key, r.value == null ? '' : String(r.value));
|
|
}
|
|
}
|
|
|
|
const payload = {
|
|
balanceRials: asInt(wallet.balance),
|
|
pricePerPartRials: asInt(wallet.price_per_part),
|
|
isBlocked: asBool(wallet.is_blocked),
|
|
chargeCard: settings.get(keys.card) || null,
|
|
chargeIban: settings.get(keys.iban) || null,
|
|
chargeHolder: settings.get(keys.holder) || null,
|
|
chargeInstruction: settings.get(keys.instruction) || null,
|
|
};
|
|
|
|
console.log('Wallet source →', payload);
|
|
|
|
let ledgers: LedgerRow[] = [];
|
|
let sendLogs: SendLogRow[] = [];
|
|
|
|
if (withHistory) {
|
|
if (await tableExists(conn, database, 'sms_ledger')) {
|
|
const [rows] = await conn.query(
|
|
`SELECT type, amount, balance_after, price_per_part, parts, note, created_by, created_at
|
|
FROM sms_ledger
|
|
ORDER BY id ASC`,
|
|
);
|
|
ledgers = rows as LedgerRow[];
|
|
}
|
|
if (await tableExists(conn, database, 'sms_send_logs')) {
|
|
const [rows] = await conn.query(
|
|
`SELECT receiver, body, parts, price_per_part, total_amount, provider, source, is_alert, created_at
|
|
FROM sms_send_logs
|
|
ORDER BY id ASC`,
|
|
);
|
|
sendLogs = rows as SendLogRow[];
|
|
}
|
|
console.log(`History source → ledger=${ledgers.length}, send_logs=${sendLogs.length}`);
|
|
}
|
|
|
|
if (dryRun) {
|
|
console.log('DRY RUN: no writes to SuperAdmin.');
|
|
return;
|
|
}
|
|
|
|
const account = await prisma.smsAccount.upsert({
|
|
where: { tenantId: tenant.id },
|
|
create: {
|
|
tenantId: tenant.id,
|
|
...payload,
|
|
alertCriticalRials: 10000,
|
|
alertWarningRials: 100000,
|
|
},
|
|
update: {
|
|
...payload,
|
|
},
|
|
});
|
|
|
|
if (withHistory) {
|
|
if (replaceHistory) {
|
|
await prisma.smsSendLog.deleteMany({ where: { smsAccountId: account.id } });
|
|
await prisma.smsLedger.deleteMany({ where: { smsAccountId: account.id } });
|
|
}
|
|
|
|
const ledgerChunk = 200;
|
|
for (let i = 0; i < ledgers.length; i += ledgerChunk) {
|
|
const slice = ledgers.slice(i, i + ledgerChunk);
|
|
await prisma.smsLedger.createMany({
|
|
data: slice.map((row) => ({
|
|
smsAccountId: account.id,
|
|
type: mapLedgerType(row.type),
|
|
amountRials: asInt(row.amount),
|
|
balanceAfterRials: asInt(row.balance_after),
|
|
parts: row.parts == null ? null : asInt(row.parts),
|
|
note: row.note,
|
|
createdById: row.created_by == null ? null : String(row.created_by),
|
|
createdAt: asDate(row.created_at),
|
|
})),
|
|
});
|
|
}
|
|
|
|
const logChunk = 200;
|
|
for (let i = 0; i < sendLogs.length; i += logChunk) {
|
|
const slice = sendLogs.slice(i, i + logChunk);
|
|
await prisma.smsSendLog.createMany({
|
|
data: slice.map((row) => ({
|
|
smsAccountId: account.id,
|
|
receiver: String(row.receiver || '').slice(0, 32) || '—',
|
|
body: String(row.body || '—').slice(0, 2000),
|
|
parts: Math.max(1, asInt(row.parts, 1)),
|
|
pricePerPartRials: asInt(row.price_per_part),
|
|
totalAmountRials: asInt(row.total_amount),
|
|
provider: row.provider ? String(row.provider).slice(0, 64) : null,
|
|
source: row.source ? String(row.source).slice(0, 64) : null,
|
|
isAlert: asBool(row.is_alert),
|
|
createdAt: asDate(row.created_at),
|
|
})),
|
|
});
|
|
}
|
|
}
|
|
|
|
await prisma.tenant.update({
|
|
where: { id: tenant.id },
|
|
data: { smsSyncedAt: new Date() },
|
|
});
|
|
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
action: 'sms.import.from_product',
|
|
entityType: 'sms_account',
|
|
entityId: account.id,
|
|
tenantId: tenant.id,
|
|
after: {
|
|
product,
|
|
domain: tenant.domain,
|
|
balanceRials: payload.balanceRials,
|
|
pricePerPartRials: payload.pricePerPartRials,
|
|
isBlocked: payload.isBlocked,
|
|
ledgerCount: ledgers.length,
|
|
sendLogCount: sendLogs.length,
|
|
mysql: { host, database },
|
|
smsSyncedAt: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
console.log('OK: SuperAdmin SMS account updated from product MySQL.', {
|
|
smsAccountId: account.id,
|
|
balanceRials: payload.balanceRials,
|
|
ledger: ledgers.length,
|
|
sendLogs: sendLogs.length,
|
|
smsSyncedAt: true,
|
|
});
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error(err);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|