Add remote SMS panel sync API and product MySQL import script.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,6 +29,11 @@ CENTRAL_SMS_PLATFORM="superadmin"
|
|||||||
# رمز مشترک SMS ریموت (HMAC + دامنه) — همان مقدار روی همه نسخههای همکار/خانگی
|
# رمز مشترک SMS ریموت (HMAC + دامنه) — همان مقدار روی همه نسخههای همکار/خانگی
|
||||||
SMS_REMOTE_SHARED_SECRET="replace-with-long-random-shared-secret"
|
SMS_REMOTE_SHARED_SECRET="replace-with-long-random-shared-secret"
|
||||||
|
|
||||||
|
# همگامسازی / مهاجرت SMS از MySQL یک نسخه (هر بار یک دامنه)
|
||||||
|
# SMS_IMPORT_DOMAIN=montpar.ir
|
||||||
|
# SMS_IMPORT_PRODUCT=hamkar
|
||||||
|
# SMS_IMPORT_DRY_RUN=1
|
||||||
|
# SMS_IMPORT_HISTORY=1
|
||||||
HAMKAR_DB_HOST=127.0.0.1
|
HAMKAR_DB_HOST=127.0.0.1
|
||||||
HAMKAR_DB_PORT=3306
|
HAMKAR_DB_PORT=3306
|
||||||
HAMKAR_DB_USER=root
|
HAMKAR_DB_USER=root
|
||||||
|
|||||||
+2
-1
@@ -26,7 +26,8 @@
|
|||||||
"prisma:backfill-appearance": "ts-node --transpile-only prisma/backfill-appearance-from-registry.ts",
|
"prisma:backfill-appearance": "ts-node --transpile-only prisma/backfill-appearance-from-registry.ts",
|
||||||
"prisma:seed-hamkar-defaults": "ts-node --transpile-only prisma/seed-hamkar-default-snapshots.ts",
|
"prisma:seed-hamkar-defaults": "ts-node --transpile-only prisma/seed-hamkar-default-snapshots.ts",
|
||||||
"prisma:seed-khanegi-defaults": "ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts",
|
"prisma:seed-khanegi-defaults": "ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts",
|
||||||
"prisma:ensure-pilot-sms-tokens": "ts-node --transpile-only prisma/ensure-pilot-sms-tokens.ts"
|
"prisma:ensure-pilot-sms-tokens": "ts-node --transpile-only prisma/ensure-pilot-sms-tokens.ts",
|
||||||
|
"prisma:import-sms-from-product": "ts-node --transpile-only prisma/import-sms-from-product.ts"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node --transpile-only prisma/seed.ts"
|
"seed": "ts-node --transpile-only prisma/seed.ts"
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
/**
|
||||||
|
* 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, table: string) {
|
||||||
|
const db =
|
||||||
|
env('SMS_IMPORT_DB_DATABASE') ||
|
||||||
|
env('HAMKAR_DB_DATABASE') ||
|
||||||
|
env('KHANEGI_DB_DATABASE');
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT 1 AS ok
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = ? AND table_name = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[db, 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, 'sms_wallets'))) {
|
||||||
|
console.log('SKIP: sms_wallets table missing — billing not installed on this version.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let smsPanel = '';
|
||||||
|
if (await tableExists(conn, '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, '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, '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, '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.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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('OK: SuperAdmin SMS account updated from product MySQL.', {
|
||||||
|
smsAccountId: account.id,
|
||||||
|
balanceRials: payload.balanceRials,
|
||||||
|
ledger: ledgers.length,
|
||||||
|
sendLogs: sendLogs.length,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type, Transform } from 'class-transformer';
|
||||||
|
|
||||||
export class UpdateSmsAccountDto {
|
export class UpdateSmsAccountDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -144,3 +144,56 @@ export class RemoteSmsConsumeDto {
|
|||||||
isAlert?: boolean;
|
isAlert?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Product panel → charge wallet (amount or amountRials) */
|
||||||
|
export class RemoteSmsChargeDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
amount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
amountRials?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoteSmsPriceDto {
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
price_per_part!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoteSmsBlockDto {
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value === true || value === 'true' || value === 1 || value === '1') return true;
|
||||||
|
if (value === false || value === 'false' || value === 0 || value === '0') return false;
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
is_blocked!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Product charge-info payload (Hamkar/Khanegi snake_case) */
|
||||||
|
export class RemoteSmsChargeInfoDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
card?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
iban?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
account_holder?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
instruction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { RemoteTenant } from '../auth/remote-tenant.decorator';
|
import { RemoteTenant } from '../auth/remote-tenant.decorator';
|
||||||
import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard';
|
import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard';
|
||||||
import { RemoteSmsConsumeDto } from './dto/sms-account.dto';
|
import {
|
||||||
|
RemoteSmsBlockDto,
|
||||||
|
RemoteSmsChargeDto,
|
||||||
|
RemoteSmsChargeInfoDto,
|
||||||
|
RemoteSmsConsumeDto,
|
||||||
|
RemoteSmsPriceDto,
|
||||||
|
SmsTableQueryDto,
|
||||||
|
} from './dto/sms-account.dto';
|
||||||
import { SmsService } from './sms.service';
|
import { SmsService } from './sms.service';
|
||||||
|
|
||||||
type RemoteTenantInfo = {
|
type RemoteTenantInfo = {
|
||||||
@@ -13,14 +28,15 @@ type RemoteTenantInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Product apps → SuperAdmin SMS wallet.
|
* Product apps → SuperAdmin SMS wallet + panel sync.
|
||||||
*
|
*
|
||||||
* Auth (preferred): shared HMAC
|
* Auth (preferred): shared HMAC
|
||||||
* X-Abshode-Domain, X-Abshode-Timestamp, X-Abshode-Signature
|
* X-Abshode-Domain, X-Abshode-Timestamp, X-Abshode-Signature
|
||||||
* Fallback: Authorization Bearer {Tenant.remoteApiToken}
|
* Fallback: Authorization Bearer {Tenant.remoteApiToken}
|
||||||
*
|
*
|
||||||
* GET /api/remote/sms/status?parts=1
|
* Gate: GET /api/remote/sms/status
|
||||||
* POST /api/remote/sms/consume
|
* Consume: POST /api/remote/sms/consume
|
||||||
|
* Panel: overview / charge-info / charge / price / block / logs / ledger / report
|
||||||
*/
|
*/
|
||||||
@Controller('remote/sms')
|
@Controller('remote/sms')
|
||||||
@UseGuards(RemoteSmsAuthGuard)
|
@UseGuards(RemoteSmsAuthGuard)
|
||||||
@@ -45,4 +61,131 @@ export class RemoteSmsController {
|
|||||||
) {
|
) {
|
||||||
return this.smsService.remoteConsume(tenant.id, dto);
|
return this.smsService.remoteConsume(tenant.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('overview')
|
||||||
|
overview(@RemoteTenant() tenant: RemoteTenantInfo) {
|
||||||
|
return this.smsService.remoteOverview(tenant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('balance')
|
||||||
|
balance(@RemoteTenant() tenant: RemoteTenantInfo) {
|
||||||
|
return this.smsService.remoteBalance(tenant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('charge-info')
|
||||||
|
chargeInfo(@RemoteTenant() tenant: RemoteTenantInfo) {
|
||||||
|
return this.smsService.remoteChargeInfo(tenant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('charge-info')
|
||||||
|
updateChargeInfo(
|
||||||
|
@RemoteTenant() tenant: RemoteTenantInfo,
|
||||||
|
@Body() dto: RemoteSmsChargeInfoDto,
|
||||||
|
) {
|
||||||
|
return this.smsService.remoteUpdateChargeInfo(tenant.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('charge')
|
||||||
|
charge(
|
||||||
|
@RemoteTenant() tenant: RemoteTenantInfo,
|
||||||
|
@Body() dto: RemoteSmsChargeDto,
|
||||||
|
) {
|
||||||
|
return this.smsService.remoteCharge(tenant.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('price')
|
||||||
|
updatePrice(
|
||||||
|
@RemoteTenant() tenant: RemoteTenantInfo,
|
||||||
|
@Body() dto: RemoteSmsPriceDto,
|
||||||
|
) {
|
||||||
|
return this.smsService.remoteUpdatePrice(tenant.id, dto.price_per_part);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('block')
|
||||||
|
updateBlock(
|
||||||
|
@RemoteTenant() tenant: RemoteTenantInfo,
|
||||||
|
@Body() dto: RemoteSmsBlockDto,
|
||||||
|
) {
|
||||||
|
return this.smsService.remoteSetBlocked(tenant.id, dto.is_blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logs/table')
|
||||||
|
logsTable(
|
||||||
|
@RemoteTenant() tenant: RemoteTenantInfo,
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
return this.smsService.remoteLogsTable(
|
||||||
|
tenant.id,
|
||||||
|
this.toTableQuery(body),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ledger/table')
|
||||||
|
ledgerTable(
|
||||||
|
@RemoteTenant() tenant: RemoteTenantInfo,
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
return this.smsService.remoteLedgerTable(
|
||||||
|
tenant.id,
|
||||||
|
this.toTableQuery(body),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('report')
|
||||||
|
report(
|
||||||
|
@RemoteTenant() tenant: RemoteTenantInfo,
|
||||||
|
@Query('from_date') fromDate?: string,
|
||||||
|
@Query('to_date') toDate?: string,
|
||||||
|
@Query('fromDate') fromDateCamel?: string,
|
||||||
|
@Query('toDate') toDateCamel?: string,
|
||||||
|
) {
|
||||||
|
return this.smsService.remotePeriodReport(
|
||||||
|
tenant.id,
|
||||||
|
fromDate || fromDateCamel,
|
||||||
|
toDate || toDateCamel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('report/charges')
|
||||||
|
chargeReport(
|
||||||
|
@RemoteTenant() tenant: RemoteTenantInfo,
|
||||||
|
@Query('from_date') fromDate?: string,
|
||||||
|
@Query('to_date') toDate?: string,
|
||||||
|
@Query('fromDate') fromDateCamel?: string,
|
||||||
|
@Query('toDate') toDateCamel?: string,
|
||||||
|
) {
|
||||||
|
return this.smsService.remoteChargeReport(
|
||||||
|
tenant.id,
|
||||||
|
fromDate || fromDateCamel,
|
||||||
|
toDate || toDateCamel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toTableQuery(body: Record<string, unknown>): SmsTableQueryDto {
|
||||||
|
const page = Number(body.page) || 1;
|
||||||
|
const pageSize =
|
||||||
|
Number(body.pageSize) || Number(body.limit) || 20;
|
||||||
|
const search =
|
||||||
|
(typeof body.search === 'string' && body.search) ||
|
||||||
|
(typeof body.text_search === 'string' && body.text_search) ||
|
||||||
|
undefined;
|
||||||
|
const fromDate =
|
||||||
|
(typeof body.from_date === 'string' && body.from_date) ||
|
||||||
|
(typeof body.fromDate === 'string' && body.fromDate) ||
|
||||||
|
undefined;
|
||||||
|
const toDate =
|
||||||
|
(typeof body.to_date === 'string' && body.to_date) ||
|
||||||
|
(typeof body.toDate === 'string' && body.toDate) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: Math.max(1, page),
|
||||||
|
pageSize: Math.min(100, Math.max(1, pageSize)),
|
||||||
|
search,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+231
-6
@@ -13,6 +13,8 @@ import { AdminNotifyService } from '../notifications/admin-notify.service';
|
|||||||
import { iranYmd } from '../common/iran-time.util';
|
import { iranYmd } from '../common/iran-time.util';
|
||||||
import {
|
import {
|
||||||
ChargeSmsWalletDto,
|
ChargeSmsWalletDto,
|
||||||
|
RemoteSmsChargeDto,
|
||||||
|
RemoteSmsChargeInfoDto,
|
||||||
RemoteSmsConsumeDto,
|
RemoteSmsConsumeDto,
|
||||||
SmsTableQueryDto,
|
SmsTableQueryDto,
|
||||||
UpdateSmsAccountDto,
|
UpdateSmsAccountDto,
|
||||||
@@ -86,7 +88,7 @@ export class SmsService {
|
|||||||
summary: await this.toSummaryFromLogs(account),
|
summary: await this.toSummaryFromLogs(account),
|
||||||
ledgers,
|
ledgers,
|
||||||
safeNote:
|
safeNote:
|
||||||
'این پنل متعلق به سوپرادمین است و فعلاً پنل پیامکی داخل همکار/خانگی را تغییر نمیدهد.',
|
'منبع حقیقت کیف پول SMS این پنل است؛ پنل پیامک همکار/خانگی وقتی remote فعال باشد از همین داده همگام میشود.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +367,231 @@ export class SmsService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Product panel: overview in Hamkar/Khanegi shape */
|
||||||
|
async remoteOverview(tenantId: string) {
|
||||||
|
const account = await this.ensureAccount(tenantId);
|
||||||
|
const usage = await this.computeUsageStats(account.id);
|
||||||
|
const charge = this.chargeInfoOf(account);
|
||||||
|
|
||||||
|
return {
|
||||||
|
wallet: {
|
||||||
|
balance: account.balanceRials,
|
||||||
|
price_per_part: account.pricePerPartRials,
|
||||||
|
is_blocked: account.isBlocked,
|
||||||
|
alert_days_sent: 0,
|
||||||
|
last_alert_date: null as string | null,
|
||||||
|
},
|
||||||
|
charge_info: {
|
||||||
|
card: charge.card,
|
||||||
|
iban: charge.iban,
|
||||||
|
account_holder: charge.accountHolder,
|
||||||
|
instruction: charge.instruction,
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
today: {
|
||||||
|
parts: usage.today.parts,
|
||||||
|
amount: usage.today.amount,
|
||||||
|
messages: usage.today.messages,
|
||||||
|
},
|
||||||
|
month: {
|
||||||
|
parts: usage.month.parts,
|
||||||
|
amount: usage.month.amount,
|
||||||
|
messages: usage.month.messages,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source: 'superadmin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteBalance(tenantId: string) {
|
||||||
|
const account = await this.ensureAccount(tenantId);
|
||||||
|
return {
|
||||||
|
balance: account.balanceRials,
|
||||||
|
is_blocked: account.isBlocked,
|
||||||
|
price_per_part: account.pricePerPartRials,
|
||||||
|
source: 'superadmin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteChargeInfo(tenantId: string) {
|
||||||
|
const charge = await this.getChargeInfo(tenantId);
|
||||||
|
return {
|
||||||
|
card: charge.card,
|
||||||
|
iban: charge.iban,
|
||||||
|
account_holder: charge.accountHolder,
|
||||||
|
instruction: charge.instruction,
|
||||||
|
source: 'superadmin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteUpdateChargeInfo(tenantId: string, dto: RemoteSmsChargeInfoDto) {
|
||||||
|
const updated = await this.updateChargeInfo(tenantId, {
|
||||||
|
chargeCard: dto.card,
|
||||||
|
chargeIban: dto.iban,
|
||||||
|
chargeHolder: dto.account_holder,
|
||||||
|
chargeInstruction: dto.instruction,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
message: 'اطلاعات واریز شارژ ذخیره شد.',
|
||||||
|
charge_info: {
|
||||||
|
card: updated.card,
|
||||||
|
iban: updated.iban,
|
||||||
|
account_holder: updated.accountHolder,
|
||||||
|
instruction: updated.instruction,
|
||||||
|
},
|
||||||
|
source: 'superadmin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteCharge(tenantId: string, dto: RemoteSmsChargeDto) {
|
||||||
|
const amountRials = dto.amountRials ?? dto.amount;
|
||||||
|
if (amountRials == null || !Number.isFinite(amountRials) || amountRials === 0) {
|
||||||
|
throw new BadRequestException('مبلغ شارژ نامعتبر است');
|
||||||
|
}
|
||||||
|
await this.chargeWallet(tenantId, {
|
||||||
|
amountRials,
|
||||||
|
note: dto.note,
|
||||||
|
});
|
||||||
|
const account = await this.ensureAccount(tenantId);
|
||||||
|
return {
|
||||||
|
message: 'کیف پول با موفقیت شارژ شد.',
|
||||||
|
balance: account.balanceRials,
|
||||||
|
price_per_part: account.pricePerPartRials,
|
||||||
|
is_blocked: account.isBlocked,
|
||||||
|
source: 'superadmin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteUpdatePrice(tenantId: string, pricePerPart: number) {
|
||||||
|
await this.updateAccount(tenantId, {
|
||||||
|
pricePerPartRials: Math.max(0, Math.floor(pricePerPart) || 0),
|
||||||
|
});
|
||||||
|
const account = await this.ensureAccount(tenantId);
|
||||||
|
return {
|
||||||
|
message: 'قیمت هر پارت بهروزرسانی شد.',
|
||||||
|
balance: account.balanceRials,
|
||||||
|
price_per_part: account.pricePerPartRials,
|
||||||
|
is_blocked: account.isBlocked,
|
||||||
|
source: 'superadmin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteSetBlocked(tenantId: string, isBlocked: boolean) {
|
||||||
|
await this.setBlocked(tenantId, isBlocked);
|
||||||
|
const account = await this.ensureAccount(tenantId);
|
||||||
|
return {
|
||||||
|
message: isBlocked ? 'ارسال پیامک مسدود شد.' : 'ارسال پیامک فعال شد.',
|
||||||
|
balance: account.balanceRials,
|
||||||
|
price_per_part: account.pricePerPartRials,
|
||||||
|
is_blocked: account.isBlocked,
|
||||||
|
source: 'superadmin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteLogsTable(
|
||||||
|
tenantId: string,
|
||||||
|
dto: SmsTableQueryDto,
|
||||||
|
rawBody: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const result = await this.getLogsTable(tenantId, dto);
|
||||||
|
return this.toProductTable(
|
||||||
|
result,
|
||||||
|
result.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
receiver: row.receiver,
|
||||||
|
body: row.body,
|
||||||
|
parts: row.parts,
|
||||||
|
price_per_part: row.pricePerPart,
|
||||||
|
total_amount: row.totalAmount,
|
||||||
|
provider: row.provider,
|
||||||
|
source: row.source,
|
||||||
|
is_alert: row.isAlert,
|
||||||
|
created_at: row.createdAt,
|
||||||
|
})),
|
||||||
|
rawBody,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteLedgerTable(
|
||||||
|
tenantId: string,
|
||||||
|
dto: SmsTableQueryDto,
|
||||||
|
rawBody: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const result = await this.getLedgerTable(tenantId, dto);
|
||||||
|
return this.toProductTable(
|
||||||
|
result,
|
||||||
|
result.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
amount: row.amount,
|
||||||
|
balance_after: row.balanceAfter,
|
||||||
|
price_per_part: row.pricePerPart,
|
||||||
|
parts: row.parts,
|
||||||
|
note: row.note,
|
||||||
|
created_by: row.createdBy,
|
||||||
|
created_at: row.createdAt,
|
||||||
|
})),
|
||||||
|
rawBody,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remotePeriodReport(tenantId: string, fromDate?: string, toDate?: string) {
|
||||||
|
const report = await this.getPeriodReport(tenantId, fromDate, toDate);
|
||||||
|
return {
|
||||||
|
from_date: report.fromDate,
|
||||||
|
to_date: report.toDate,
|
||||||
|
balance: report.balance,
|
||||||
|
summary: report.summary,
|
||||||
|
by_provider: report.byProvider.map((row) => ({
|
||||||
|
provider: row.provider,
|
||||||
|
messages: row.messages,
|
||||||
|
parts: row.parts,
|
||||||
|
amount: row.amount,
|
||||||
|
})),
|
||||||
|
source: 'superadmin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteChargeReport(tenantId: string, fromDate?: string, toDate?: string) {
|
||||||
|
const report = await this.getChargeReport(tenantId, fromDate, toDate);
|
||||||
|
return {
|
||||||
|
from_date: report.fromDate,
|
||||||
|
to_date: report.toDate,
|
||||||
|
summary: report.summary,
|
||||||
|
items: report.items.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
amount: row.amount,
|
||||||
|
balance_after: row.balanceAfter,
|
||||||
|
note: row.note,
|
||||||
|
created_by: row.createdBy,
|
||||||
|
created_at: row.createdAt,
|
||||||
|
})),
|
||||||
|
source: 'superadmin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toProductTable(
|
||||||
|
result: { page: number; pageSize: number; total: number },
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
rawBody: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
query: {
|
||||||
|
limit: result.pageSize,
|
||||||
|
filters: rawBody.filters ?? null,
|
||||||
|
filtersOpt: rawBody.filtersOpt ?? null,
|
||||||
|
search: rawBody.search ?? rawBody.text_search ?? null,
|
||||||
|
key: rawBody.key ?? null,
|
||||||
|
select: rawBody.select ?? null,
|
||||||
|
total: result.total,
|
||||||
|
sort: null,
|
||||||
|
page: result.page,
|
||||||
|
},
|
||||||
|
rows,
|
||||||
|
source: 'superadmin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getPeriodReport(tenantId: string, fromDate?: string, toDate?: string) {
|
async getPeriodReport(tenantId: string, fromDate?: string, toDate?: string) {
|
||||||
const account = await this.ensureAccount(tenantId);
|
const account = await this.ensureAccount(tenantId);
|
||||||
const range = this.resolveDateRange(fromDate, toDate, true);
|
const range = this.resolveDateRange(fromDate, toDate, true);
|
||||||
@@ -561,14 +788,12 @@ export class SmsService {
|
|||||||
const existing = await this.prisma.smsAccount.findUnique({ where: { tenantId } });
|
const existing = await this.prisma.smsAccount.findUnique({ where: { tenantId } });
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const demoBalances = [250000, 85000, 7500, 150000, 12000, 3000, 500000, 45000];
|
// Start at zero — real balances come from product MySQL import
|
||||||
const hash = [...tenantId].reduce((sum, ch) => sum + ch.charCodeAt(0), 0);
|
// (prisma/import-sms-from-product.ts), not demo numbers.
|
||||||
const balanceRials = demoBalances[hash % demoBalances.length];
|
|
||||||
|
|
||||||
return this.prisma.smsAccount.create({
|
return this.prisma.smsAccount.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
balanceRials,
|
balanceRials: 0,
|
||||||
pricePerPartRials: 2500,
|
pricePerPartRials: 2500,
|
||||||
alertCriticalRials: 10000,
|
alertCriticalRials: 10000,
|
||||||
alertWarningRials: 100000,
|
alertWarningRials: 100000,
|
||||||
|
|||||||
Reference in New Issue
Block a user