Files
api/prisma/import-sms-from-product.ts

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();
});