Add remote SMS panel sync API and product MySQL import script.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 20:53:59 +03:30
co-authored by Cursor
parent 57654e295c
commit 17a54774ae
6 changed files with 850 additions and 13 deletions
+5
View File
@@ -29,6 +29,11 @@ CENTRAL_SMS_PLATFORM="superadmin"
# رمز مشترک SMS ریموت (HMAC + دامنه) — همان مقدار روی همه نسخه‌های همکار/خانگی
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_PORT=3306
HAMKAR_DB_USER=root
+2 -1
View File
@@ -26,7 +26,8 @@
"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-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": {
"seed": "ts-node --transpile-only prisma/seed.ts"
+410
View File
@@ -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();
});
+54 -1
View File
@@ -7,7 +7,7 @@ import {
Max,
Min,
} from 'class-validator';
import { Type } from 'class-transformer';
import { Type, Transform } from 'class-transformer';
export class UpdateSmsAccountDto {
@IsOptional()
@@ -144,3 +144,56 @@ export class RemoteSmsConsumeDto {
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;
}
+148 -5
View File
@@ -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 { 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';
type RemoteTenantInfo = {
@@ -13,14 +28,15 @@ type RemoteTenantInfo = {
};
/**
* Product apps → SuperAdmin SMS wallet.
* Product apps → SuperAdmin SMS wallet + panel sync.
*
* Auth (preferred): shared HMAC
* X-Abshode-Domain, X-Abshode-Timestamp, X-Abshode-Signature
* Fallback: Authorization Bearer {Tenant.remoteApiToken}
*
* GET /api/remote/sms/status?parts=1
* POST /api/remote/sms/consume
* Gate: GET /api/remote/sms/status
* Consume: POST /api/remote/sms/consume
* Panel: overview / charge-info / charge / price / block / logs / ledger / report
*/
@Controller('remote/sms')
@UseGuards(RemoteSmsAuthGuard)
@@ -45,4 +61,131 @@ export class RemoteSmsController {
) {
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
View File
@@ -13,6 +13,8 @@ import { AdminNotifyService } from '../notifications/admin-notify.service';
import { iranYmd } from '../common/iran-time.util';
import {
ChargeSmsWalletDto,
RemoteSmsChargeDto,
RemoteSmsChargeInfoDto,
RemoteSmsConsumeDto,
SmsTableQueryDto,
UpdateSmsAccountDto,
@@ -86,7 +88,7 @@ export class SmsService {
summary: await this.toSummaryFromLogs(account),
ledgers,
safeNote:
'این پنل متعلق به سوپرادمین است و فعلاً پنل پیامکی داخل همکار/خانگی را تغییر نمی‌دهد.',
'منبع حقیقت کیف پول SMS این پنل است؛ پنل پیامک همکار/خانگی وقتی remote فعال باشد از همین داده همگام می‌شود.',
};
}
@@ -365,6 +367,231 @@ export class SmsService {
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) {
const account = await this.ensureAccount(tenantId);
const range = this.resolveDateRange(fromDate, toDate, true);
@@ -561,14 +788,12 @@ export class SmsService {
const existing = await this.prisma.smsAccount.findUnique({ where: { tenantId } });
if (existing) return existing;
const demoBalances = [250000, 85000, 7500, 150000, 12000, 3000, 500000, 45000];
const hash = [...tenantId].reduce((sum, ch) => sum + ch.charCodeAt(0), 0);
const balanceRials = demoBalances[hash % demoBalances.length];
// Start at zero — real balances come from product MySQL import
// (prisma/import-sms-from-product.ts), not demo numbers.
return this.prisma.smsAccount.create({
data: {
tenantId,
balanceRials,
balanceRials: 0,
pricePerPartRials: 2500,
alertCriticalRials: 10000,
alertWarningRials: 100000,