Add remote SMS panel sync API and product MySQL import script.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user