diff --git a/package.json b/package.json index d97a329..241264c 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/prisma/ensure-pilot-sms-tokens.ts b/prisma/ensure-pilot-sms-tokens.ts new file mode 100644 index 0000000..c7af81a --- /dev/null +++ b/prisma/ensure-pilot-sms-tokens.ts @@ -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(); + }); diff --git a/src/auth/remote-tenant.decorator.ts b/src/auth/remote-tenant.decorator.ts new file mode 100644 index 0000000..54a59fd --- /dev/null +++ b/src/auth/remote-tenant.decorator.ts @@ -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; + }, +); diff --git a/src/auth/remote-token.guard.ts b/src/auth/remote-token.guard.ts new file mode 100644 index 0000000..1fd4020 --- /dev/null +++ b/src/auth/remote-token.guard.ts @@ -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 { + 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; + } +} diff --git a/src/sms/dto/sms-account.dto.ts b/src/sms/dto/sms-account.dto.ts index f3d9ae2..1bc2de3 100644 --- a/src/sms/dto/sms-account.dto.ts +++ b/src/sms/dto/sms-account.dto.ts @@ -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; +} + diff --git a/src/sms/remote-sms.controller.ts b/src/sms/remote-sms.controller.ts new file mode 100644 index 0000000..e956bc6 --- /dev/null +++ b/src/sms/remote-sms.controller.ts @@ -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); + } +} diff --git a/src/sms/sms.module.ts b/src/sms/sms.module.ts index 6e6f825..97e1136 100644 --- a/src/sms/sms.module.ts +++ b/src/sms/sms.module.ts @@ -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 {} diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts index d1e140c..d962455 100644 --- a/src/sms/sms.service.ts +++ b/src/sms/sms.service.ts @@ -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);