Add Khanegi SMS sync inventory, batch import, and rollout helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Batch-import SMS wallets for many sold versions from a targets JSON file.
|
||||
*
|
||||
* Each target is one Khanegi/Hamkar MySQL (one sold version). Never share one
|
||||
* DB/env across multiple domains on the same host.
|
||||
*
|
||||
* SMS_IMPORT_TARGETS=./prisma/khanegi-sms-targets.example.json \
|
||||
* SMS_IMPORT_DRY_RUN=1 \
|
||||
* npm run prisma:import-sms-batch
|
||||
*
|
||||
* Target shape:
|
||||
* [
|
||||
* {
|
||||
* "domain": "example.ir",
|
||||
* "product": "khanegi",
|
||||
* "pilot": true,
|
||||
* "db": { "host": "127.0.0.1", "port": 3306, "user": "u", "password": "p", "database": "db" }
|
||||
* }
|
||||
* ]
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
type DbTarget = {
|
||||
host?: string;
|
||||
port?: number | string;
|
||||
user?: string;
|
||||
password?: string;
|
||||
database: string;
|
||||
};
|
||||
|
||||
type ImportTarget = {
|
||||
domain: string;
|
||||
product?: 'khanegi' | 'hamkar';
|
||||
pilot?: boolean;
|
||||
skip?: boolean;
|
||||
db: DbTarget;
|
||||
};
|
||||
|
||||
function env(name: string, fallback = '') {
|
||||
return String(process.env[name] ?? fallback).trim();
|
||||
}
|
||||
|
||||
function loadTargets(filePath: string): ImportTarget[] {
|
||||
const abs = path.resolve(filePath);
|
||||
if (!fs.existsSync(abs)) {
|
||||
throw new Error(`Targets file not found: ${abs}`);
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(abs, 'utf8')) as ImportTarget[];
|
||||
if (!Array.isArray(raw)) throw new Error('Targets JSON must be an array');
|
||||
return raw;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const targetsPath =
|
||||
env('SMS_IMPORT_TARGETS') ||
|
||||
path.join(__dirname, 'khanegi-sms-targets.example.json');
|
||||
const dryRun = env('SMS_IMPORT_DRY_RUN') === '1';
|
||||
const onlyPilot = env('SMS_IMPORT_ONLY_PILOT') === '1';
|
||||
const onlyDomain = env('SMS_IMPORT_DOMAIN');
|
||||
|
||||
let targets = loadTargets(targetsPath).filter((t) => !t.skip);
|
||||
if (onlyPilot) targets = targets.filter((t) => t.pilot);
|
||||
if (onlyDomain) {
|
||||
targets = targets.filter(
|
||||
(t) => t.domain.toLowerCase() === onlyDomain.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!targets.length) {
|
||||
console.error('No targets to import.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
targetsPath,
|
||||
dryRun,
|
||||
onlyPilot,
|
||||
onlyDomain: onlyDomain || null,
|
||||
count: targets.length,
|
||||
domains: targets.map((t) => t.domain),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const script = path.join(__dirname, 'import-sms-from-product.ts');
|
||||
let failed = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
const product = target.product || 'khanegi';
|
||||
const db = target.db || ({} as DbTarget);
|
||||
if (!db.database) {
|
||||
console.error(`SKIP ${target.domain}: missing db.database`);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
SMS_IMPORT_DOMAIN: target.domain,
|
||||
SMS_IMPORT_PRODUCT: product,
|
||||
SMS_IMPORT_DRY_RUN: dryRun ? '1' : env('SMS_IMPORT_DRY_RUN'),
|
||||
SMS_IMPORT_DB_HOST: String(db.host || '127.0.0.1'),
|
||||
SMS_IMPORT_DB_PORT: String(db.port || 3306),
|
||||
SMS_IMPORT_DB_USER: String(db.user || 'root'),
|
||||
SMS_IMPORT_DB_PASSWORD: String(db.password ?? ''),
|
||||
SMS_IMPORT_DB_DATABASE: String(db.database),
|
||||
};
|
||||
|
||||
console.log(`\n=== ${dryRun ? 'DRY ' : ''}IMPORT ${target.domain} (${product}) ===`);
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
['ts-node', '--transpile-only', script],
|
||||
{
|
||||
env: childEnv,
|
||||
cwd: path.join(__dirname, '..'),
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
failed += 1;
|
||||
console.error(`FAILED: ${target.domain} (exit ${result.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. failed=${failed} / total=${targets.length}`);
|
||||
if (failed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -366,6 +366,11 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.tenant.update({
|
||||
where: { id: tenant.id },
|
||||
data: { smsSyncedAt: new Date() },
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'sms.import.from_product',
|
||||
@@ -381,6 +386,7 @@ async function main() {
|
||||
ledgerCount: ledgers.length,
|
||||
sendLogCount: sendLogs.length,
|
||||
mysql: { host, database },
|
||||
smsSyncedAt: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -390,6 +396,7 @@ async function main() {
|
||||
balanceRials: payload.balanceRials,
|
||||
ledger: ledgers.length,
|
||||
sendLogs: sendLogs.length,
|
||||
smsSyncedAt: true,
|
||||
});
|
||||
} finally {
|
||||
await conn.end();
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Inventory Khanegi tenants for SMS sync rollout.
|
||||
*
|
||||
* Groups by nodeServer host (multiple versions may share one physical server).
|
||||
* Prints TSV + summary JSON. Use on the SuperAdmin host:
|
||||
*
|
||||
* npm run prisma:inventory-khanegi-sms
|
||||
* SMS_INVENTORY_FORMAT=json npm run prisma:inventory-khanegi-sms
|
||||
* SMS_INVENTORY_OUT=/tmp/khanegi-sms-inventory.json npm run prisma:inventory-khanegi-sms
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { PrismaClient, ProductType } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function env(name: string, fallback = '') {
|
||||
return String(process.env[name] ?? fallback).trim();
|
||||
}
|
||||
|
||||
function hostFromUrl(url?: string | null) {
|
||||
if (!url) return '(unknown-host)';
|
||||
return (
|
||||
String(url)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.split('/')[0]
|
||||
.split(':')[0] || '(unknown-host)'
|
||||
);
|
||||
}
|
||||
|
||||
type SyncStatus =
|
||||
| 'no_sms_account'
|
||||
| 'imported'
|
||||
| 'imported_marked'
|
||||
| 'empty_default'
|
||||
| 'inactive';
|
||||
|
||||
function classify(row: {
|
||||
isActive: boolean;
|
||||
hasAccount: boolean;
|
||||
smsSyncedAt: Date | null;
|
||||
balanceRials: number | null;
|
||||
ledgerCount: number;
|
||||
sendLogCount: number;
|
||||
}): SyncStatus {
|
||||
if (!row.isActive) return 'inactive';
|
||||
if (!row.hasAccount) return 'no_sms_account';
|
||||
if (row.smsSyncedAt) return 'imported_marked';
|
||||
if (
|
||||
(row.balanceRials ?? 0) === 0 &&
|
||||
row.ledgerCount === 0 &&
|
||||
row.sendLogCount === 0
|
||||
) {
|
||||
return 'empty_default';
|
||||
}
|
||||
return 'imported';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const format = (env('SMS_INVENTORY_FORMAT', 'tsv') || 'tsv').toLowerCase();
|
||||
const outPath = env('SMS_INVENTORY_OUT');
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { product: ProductType.khanegi },
|
||||
orderBy: [{ nodeServer: 'asc' }, { domain: 'asc' }],
|
||||
include: {
|
||||
smsAccount: {
|
||||
include: {
|
||||
_count: { select: { ledgers: true, sendLogs: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rows = tenants.map((t) => {
|
||||
const acc = t.smsAccount;
|
||||
const ledgerCount = acc?._count.ledgers ?? 0;
|
||||
const sendLogCount = acc?._count.sendLogs ?? 0;
|
||||
const balanceRials = acc?.balanceRials ?? null;
|
||||
const status = classify({
|
||||
isActive: t.isActive,
|
||||
hasAccount: !!acc,
|
||||
smsSyncedAt: t.smsSyncedAt,
|
||||
balanceRials,
|
||||
ledgerCount,
|
||||
sendLogCount,
|
||||
});
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
nameFa: t.nameFa,
|
||||
domain: t.domain,
|
||||
domains: t.domains,
|
||||
appUrl: t.appUrl,
|
||||
adminUrl: t.adminUrl,
|
||||
nodeServer: t.nodeServer,
|
||||
serverHost: hostFromUrl(t.nodeServer),
|
||||
isActive: t.isActive,
|
||||
smsSyncedAt: t.smsSyncedAt?.toISOString() ?? null,
|
||||
hasSmsAccount: !!acc,
|
||||
balanceRials,
|
||||
pricePerPartRials: acc?.pricePerPartRials ?? null,
|
||||
isBlocked: acc?.isBlocked ?? null,
|
||||
autoBlockBelowRials: acc?.autoBlockBelowRials ?? null,
|
||||
ledgerCount,
|
||||
sendLogCount,
|
||||
syncStatus: status,
|
||||
nextAction:
|
||||
status === 'inactive'
|
||||
? 'skip_inactive'
|
||||
: status === 'no_sms_account' || status === 'empty_default'
|
||||
? 'import_then_enable_remote'
|
||||
: status === 'imported' || status === 'imported_marked'
|
||||
? 'enable_remote_if_not_yet'
|
||||
: 'review',
|
||||
};
|
||||
});
|
||||
|
||||
const byHost = new Map<string, typeof rows>();
|
||||
for (const row of rows) {
|
||||
const list = byHost.get(row.serverHost) || [];
|
||||
list.push(row);
|
||||
byHost.set(row.serverHost, list);
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: rows.length,
|
||||
byStatus: rows.reduce<Record<string, number>>((acc, r) => {
|
||||
acc[r.syncStatus] = (acc[r.syncStatus] || 0) + 1;
|
||||
return acc;
|
||||
}, {}),
|
||||
multiVersionHosts: [...byHost.entries()]
|
||||
.filter(([, list]) => list.length > 1)
|
||||
.map(([host, list]) => ({
|
||||
host,
|
||||
count: list.length,
|
||||
domains: list.map((r) => r.domain),
|
||||
})),
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const payload = { summary, rows };
|
||||
|
||||
if (outPath) {
|
||||
fs.writeFileSync(outPath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
console.error(`Wrote ${outPath}`);
|
||||
}
|
||||
|
||||
if (format === 'json') {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
} else {
|
||||
console.log(
|
||||
[
|
||||
'serverHost',
|
||||
'domain',
|
||||
'nameFa',
|
||||
'syncStatus',
|
||||
'nextAction',
|
||||
'balanceRials',
|
||||
'pricePerPartRials',
|
||||
'isBlocked',
|
||||
'smsSyncedAt',
|
||||
'ledgerCount',
|
||||
'sendLogCount',
|
||||
'appUrl',
|
||||
'nodeServer',
|
||||
].join('\t'),
|
||||
);
|
||||
for (const r of rows) {
|
||||
console.log(
|
||||
[
|
||||
r.serverHost,
|
||||
r.domain,
|
||||
r.nameFa,
|
||||
r.syncStatus,
|
||||
r.nextAction,
|
||||
r.balanceRials ?? '',
|
||||
r.pricePerPartRials ?? '',
|
||||
r.isBlocked ?? '',
|
||||
r.smsSyncedAt ?? '',
|
||||
r.ledgerCount,
|
||||
r.sendLogCount,
|
||||
r.appUrl ?? '',
|
||||
r.nodeServer,
|
||||
].join('\t'),
|
||||
);
|
||||
}
|
||||
console.error(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
[
|
||||
{
|
||||
"domain": "k.montzar.ir",
|
||||
"product": "khanegi",
|
||||
"pilot": true,
|
||||
"db": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"user": "REPLACE_USER",
|
||||
"password": "REPLACE_PASSWORD",
|
||||
"database": "REPLACE_DATABASE"
|
||||
},
|
||||
"notes": "Pilot first. Put real MySQL of THIS version only — never a shared DB for all sites on the host."
|
||||
},
|
||||
{
|
||||
"domain": "example-customer.ir",
|
||||
"product": "khanegi",
|
||||
"pilot": false,
|
||||
"skip": true,
|
||||
"db": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"user": "REPLACE_USER",
|
||||
"password": "REPLACE_PASSWORD",
|
||||
"database": "REPLACE_DATABASE"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -36,8 +36,11 @@ async function main() {
|
||||
domains: t.domains,
|
||||
appUrl: t.appUrl,
|
||||
nodeServer: t.nodeServer,
|
||||
smsSyncedAt: t.smsSyncedAt,
|
||||
balanceRials: t.smsAccount?.balanceRials ?? null,
|
||||
pricePerPartRials: t.smsAccount?.pricePerPartRials ?? null,
|
||||
isBlocked: t.smsAccount?.isBlocked ?? null,
|
||||
autoBlockBelowRials: t.smsAccount?.autoBlockBelowRials ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
Reference in New Issue
Block a user