Implementing SMS panel in Super Admin

This commit is contained in:
2026-08-05 14:13:44 +03:30
parent b66e47c3b3
commit 14d9c5d143
8 changed files with 338 additions and 3 deletions
+2 -1
View File
@@ -25,7 +25,8 @@
"prisma:import-users": "ts-node --transpile-only prisma/import-users.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-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": {
"seed": "ts-node --transpile-only prisma/seed.ts"
+77
View File
@@ -0,0 +1,77 @@
/**
* Ensure remoteApiToken for SMS pilot tenants (montpar.ir, k.montzar.ir).
* Does NOT print token values — only reports which tenants were updated.
*
* Usage: npx ts-node --transpile-only prisma/ensure-pilot-sms-tokens.ts
*/
import { randomBytes } from 'crypto';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const PILOT_DOMAINS = ['montpar.ir', 'k.montzar.ir'];
function domainKeys(tenant: {
domain: string | null;
domains: unknown;
}): string[] {
const keys = [
tenant.domain?.toLowerCase(),
...((Array.isArray(tenant.domains) ? tenant.domains : []) as string[]).map((d) =>
String(d).toLowerCase(),
),
].filter(Boolean) as string[];
return keys;
}
async function main() {
const tenants = await prisma.tenant.findMany({
where: { isActive: true },
select: {
id: true,
nameFa: true,
domain: true,
domains: true,
remoteApiToken: true,
product: true,
},
});
let created = 0;
let kept = 0;
for (const domain of PILOT_DOMAINS) {
const tenant = tenants.find((row) => domainKeys(row).includes(domain));
if (!tenant) {
console.log(`missing tenant for domain=${domain}`);
continue;
}
if (tenant.remoteApiToken && tenant.remoteApiToken.length >= 24) {
kept += 1;
console.log(`ok (token exists): ${tenant.nameFa} [${tenant.product}] ${domain}`);
continue;
}
const token = randomBytes(32).toString('hex');
await prisma.tenant.update({
where: { id: tenant.id },
data: { remoteApiToken: token },
});
created += 1;
console.log(
`token created: ${tenant.nameFa} [${tenant.product}] ${domain} — copy from DB/UI into product SUPERADMIN_REMOTE_TOKEN`,
);
}
console.log({ pilots: PILOT_DOMAINS.length, created, kept });
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+8
View File
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const RemoteTenant = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.remoteTenant;
},
);
+44
View File
@@ -0,0 +1,44 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class RemoteTokenGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const header = String(req.headers?.authorization || '');
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
const token = match?.[1]?.trim();
if (!token || token.length < 16) {
throw new UnauthorizedException('توکن نسخه نامعتبر است');
}
const tenant = await this.prisma.tenant.findFirst({
where: {
remoteApiToken: token,
isActive: true,
},
select: {
id: true,
nameFa: true,
domain: true,
product: true,
isActive: true,
},
});
if (!tenant) {
throw new UnauthorizedException('توکن نسخه نامعتبر است');
}
req.remoteTenant = tenant;
return true;
}
}
+29
View File
@@ -115,3 +115,32 @@ export class SmsTableQueryDto {
@IsString()
search?: string;
}
/** Body for product → SuperAdmin SMS consume (after successful gateway send) */
export class RemoteSmsConsumeDto {
@IsString()
receiver!: string;
@IsOptional()
@IsString()
body?: string;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
parts!: number;
@IsOptional()
@IsString()
provider?: string;
@IsOptional()
@IsString()
source?: string;
@IsOptional()
@IsBoolean()
isAlert?: boolean;
}
+45
View File
@@ -0,0 +1,45 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { RemoteTenant } from '../auth/remote-tenant.decorator';
import { RemoteTokenGuard } from '../auth/remote-token.guard';
import { RemoteSmsConsumeDto } from './dto/sms-account.dto';
import { SmsService } from './sms.service';
type RemoteTenantInfo = {
id: string;
nameFa: string;
domain: string | null;
product: string;
isActive: boolean;
};
/**
* Product apps (Hamkar/Khanegi) → SuperAdmin SMS wallet.
* Auth: Authorization: Bearer {Tenant.remoteApiToken}
*
* GET /api/remote/sms/status?parts=1
* POST /api/remote/sms/consume
*/
@Controller('remote/sms')
@UseGuards(RemoteTokenGuard)
export class RemoteSmsController {
constructor(private readonly smsService: SmsService) {}
@Get('status')
status(
@RemoteTenant() tenant: RemoteTenantInfo,
@Query('parts') parts?: string,
@Query('isAlert') isAlert?: string,
) {
const n = Math.max(1, Math.min(50, Number(parts) || 1));
const alert = isAlert === '1' || isAlert === 'true';
return this.smsService.remoteStatus(tenant.id, n, alert);
}
@Post('consume')
consume(
@RemoteTenant() tenant: RemoteTenantInfo,
@Body() dto: RemoteSmsConsumeDto,
) {
return this.smsService.remoteConsume(tenant.id, dto);
}
}
+4 -2
View File
@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { RemoteSmsController } from './remote-sms.controller';
import { SmsController } from './sms.controller';
import { SmsService } from './sms.service';
import { RemoteTokenGuard } from '../auth/remote-token.guard';
@Module({
imports: [NotificationsModule],
controllers: [SmsController],
providers: [SmsService],
controllers: [SmsController, RemoteSmsController],
providers: [SmsService, RemoteTokenGuard],
exports: [SmsService],
})
export class SmsModule {}
+129
View File
@@ -1,5 +1,8 @@
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -10,6 +13,7 @@ import { AdminNotifyService } from '../notifications/admin-notify.service';
import { iranYmd } from '../common/iran-time.util';
import {
ChargeSmsWalletDto,
RemoteSmsConsumeDto,
SmsTableQueryDto,
UpdateSmsAccountDto,
UpdateSmsChargeInfoDto,
@@ -236,6 +240,131 @@ export class SmsService {
return this.updateAccount(tenantId, { isBlocked }, userId);
}
/** Product apps: pre-send gate */
async remoteStatus(tenantId: string, parts = 1, isAlert = false) {
const account = await this.ensureAccount(tenantId);
const safeParts = Math.max(1, Math.min(50, Math.floor(parts) || 1));
const estimatedCostRials = safeParts * account.pricePerPartRials;
const balanceLevel = this.balanceLevel(
account.balanceRials,
account.alertCriticalRials,
account.alertWarningRials,
);
let reason: string | null = null;
let allowed = true;
if (account.isBlocked && !isAlert) {
allowed = false;
reason = 'blocked';
} else if (!isAlert && account.balanceRials < estimatedCostRials) {
allowed = false;
reason = 'insufficient_balance';
}
return {
allowed,
blocked: account.isBlocked,
reason,
balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials,
parts: safeParts,
estimatedCostRials,
balanceLevel,
};
}
/** Product apps: post-send debit + send log */
async remoteConsume(tenantId: string, dto: RemoteSmsConsumeDto) {
const parts = Math.max(1, Math.min(50, Math.floor(dto.parts) || 1));
const receiver = String(dto.receiver || '').trim().slice(0, 32);
if (!receiver) {
throw new BadRequestException('شماره گیرنده الزامی است');
}
const body = String(dto.body || '')
.trim()
.slice(0, 1000);
const provider = dto.provider ? String(dto.provider).trim().slice(0, 64) : null;
const source = dto.source ? String(dto.source).trim().slice(0, 64) : null;
const isAlert = Boolean(dto.isAlert);
const result = await this.prisma.$transaction(async (tx) => {
const account = await tx.smsAccount.findUnique({ where: { tenantId } });
if (!account) {
throw new NotFoundException('حساب پیامک یافت نشد');
}
if (account.isBlocked && !isAlert) {
throw new ForbiddenException({
message: 'ارسال پیامک برای این نسخه مسدود است',
reason: 'blocked',
});
}
const costRials = parts * account.pricePerPartRials;
if (!isAlert && account.balanceRials < costRials) {
throw new HttpException(
{
message: 'موجودی کیف پول پیامک کافی نیست',
reason: 'insufficient_balance',
balanceRials: account.balanceRials,
costRials,
},
HttpStatus.PAYMENT_REQUIRED,
);
}
const nextBalance = account.balanceRials - costRials;
const updated = await tx.smsAccount.update({
where: { id: account.id },
data: { balanceRials: nextBalance },
});
await tx.smsLedger.create({
data: {
smsAccountId: account.id,
type: 'debit',
amountRials: -costRials,
balanceAfterRials: nextBalance,
parts,
note: source ? `مصرف ارسال (${source})` : 'مصرف ارسال',
},
});
await tx.smsSendLog.create({
data: {
smsAccountId: account.id,
receiver,
body: body || '—',
parts,
pricePerPartRials: account.pricePerPartRials,
totalAmountRials: costRials,
provider,
source,
isAlert,
},
});
return {
ok: true as const,
balanceRials: updated.balanceRials,
costRials,
parts,
pricePerPartRials: account.pricePerPartRials,
balanceLevel: this.balanceLevel(
updated.balanceRials,
account.alertCriticalRials,
account.alertWarningRials,
),
};
});
return result;
}
async getPeriodReport(tenantId: string, fromDate?: string, toDate?: string) {
const account = await this.ensureAccount(tenantId);
const range = this.resolveDateRange(fromDate, toDate, true);