Brand OTP SMS with business name and support local SMS proxy.
Append tenant Farsi name to verification SMS, allow optional domain on send-otp, and add SMS_PROXY_* for local delivery via production. Also include websites SSL sync agent/API wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
77f3cb2a67
commit
8f05ee2b58
@@ -80,7 +80,7 @@ export class AuthController {
|
||||
|
||||
@Post('send-otp')
|
||||
sendOtp(@Body() dto: SendOtpDto) {
|
||||
return this.authService.sendOtp(dto.cellNumber);
|
||||
return this.authService.sendOtp(dto.cellNumber, dto.domain);
|
||||
}
|
||||
|
||||
@Post('verify-otp')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
@@ -271,7 +272,7 @@ export class AuthService {
|
||||
return { message: 'Password changed successfully' };
|
||||
}
|
||||
|
||||
async sendOtp(cellNumber: string) {
|
||||
async sendOtp(cellNumber: string, domain?: string) {
|
||||
if (!this.sms.isEnabled()) {
|
||||
return {
|
||||
enabled: false,
|
||||
@@ -288,15 +289,28 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Cell number is not registered');
|
||||
}
|
||||
|
||||
let businessNameFa: string | undefined;
|
||||
const host = domain?.trim();
|
||||
if (host) {
|
||||
try {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
businessNameFa =
|
||||
business.nameFa?.trim() || business.name?.trim() || undefined;
|
||||
} catch {
|
||||
// Domain may be unknown — still send OTP without branding suffix
|
||||
}
|
||||
}
|
||||
|
||||
const code = this.generateOtpCode();
|
||||
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
|
||||
|
||||
try {
|
||||
await this.sms.sendVerificationCode(cellNumber, code);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException(
|
||||
'SMS provider is not configured yet',
|
||||
);
|
||||
await this.sms.sendVerificationCode(cellNumber, code, businessNameFa);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) {
|
||||
throw err;
|
||||
}
|
||||
throw new ServiceUnavailableException('Unable to send SMS');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class SendOtpDto {
|
||||
@IsString()
|
||||
@@ -6,4 +6,10 @@ export class SendOtpDto {
|
||||
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||
})
|
||||
cellNumber!: string;
|
||||
|
||||
/** Tenant host/apex (e.g. sanihome.ir or business.sanihome.ir) — used to brand OTP SMS. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(253)
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
+85
-2
@@ -27,7 +27,11 @@ export class SmsService {
|
||||
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
|
||||
}
|
||||
|
||||
async sendVerificationCode(cellNumber: string, code: string): Promise<SmsSendResult> {
|
||||
async sendVerificationCode(
|
||||
cellNumber: string,
|
||||
code: string,
|
||||
businessNameFa?: string,
|
||||
): Promise<SmsSendResult> {
|
||||
if (!this.isEnabled()) {
|
||||
this.logger.warn(
|
||||
`SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`,
|
||||
@@ -35,7 +39,10 @@ export class SmsService {
|
||||
return { serverId: 'disabled' };
|
||||
}
|
||||
|
||||
const message = `کد تایید شما: ${code}`;
|
||||
const brand = businessNameFa?.trim();
|
||||
const message = brand
|
||||
? `کد تایید شما: ${code}\n${brand}`
|
||||
: `کد تایید شما: ${code}`;
|
||||
return this.sendMessage(cellNumber, message);
|
||||
}
|
||||
|
||||
@@ -58,9 +65,85 @@ export class SmsService {
|
||||
throw new BadRequestException('Invalid cellphone number');
|
||||
}
|
||||
|
||||
if (this.isProxyConfigured()) {
|
||||
return this.sendViaProxy(destination, text);
|
||||
}
|
||||
|
||||
return this.sendQuick(destination, text);
|
||||
}
|
||||
|
||||
private isProxyConfigured(): boolean {
|
||||
const url = this.config.get<string>('SMS_PROXY_URL')?.trim();
|
||||
const apiKey = this.config.get<string>('SMS_PROXY_API_KEY')?.trim();
|
||||
const domain = this.config.get<string>('SMS_PROXY_DOMAIN')?.trim();
|
||||
return Boolean(url && apiKey && domain);
|
||||
}
|
||||
|
||||
/** Local-dev path: deliver via remote Meshkee partner SMS gateway (production reaches Gama). */
|
||||
private async sendViaProxy(destination: string, message: string): Promise<SmsSendResult> {
|
||||
const url = this.config.get<string>('SMS_PROXY_URL')!.trim();
|
||||
const apiKey = this.config.get<string>('SMS_PROXY_API_KEY')!.trim();
|
||||
const domain = this.config.get<string>('SMS_PROXY_DOMAIN')!.trim();
|
||||
const masked = maskMsisdn(destination);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
to: destination,
|
||||
message,
|
||||
}),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`SMS proxy network error for ${masked}: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('SMS provider is unreachable');
|
||||
}
|
||||
|
||||
let payload: { success?: boolean; serverId?: string; message?: string } = {};
|
||||
try {
|
||||
payload = (await response.json()) as typeof payload;
|
||||
} catch {
|
||||
this.logger.error(`SMS proxy returned non-JSON for ${masked} (HTTP ${response.status})`);
|
||||
throw new ServiceUnavailableException('SMS provider returned an invalid response');
|
||||
}
|
||||
|
||||
if (!response.ok || !payload.success) {
|
||||
const providerMessage =
|
||||
typeof payload.message === 'string' && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: `HTTP ${response.status}`;
|
||||
this.logger.warn(`SMS proxy failed for ${masked}: ${providerMessage}`);
|
||||
if (response.status === 400) {
|
||||
throw new BadRequestException(providerMessage || 'Invalid SMS destination or sender');
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new ServiceUnavailableException('SMS provider authentication failed');
|
||||
}
|
||||
throw new ServiceUnavailableException(
|
||||
providerMessage || 'SMS provider rejected the request',
|
||||
);
|
||||
}
|
||||
|
||||
const serverId = String(payload.serverId ?? '');
|
||||
if (!serverId) {
|
||||
this.logger.warn(`SMS proxy succeeded without serverId for ${masked}`);
|
||||
throw new ServiceUnavailableException('SMS provider returned an empty server id');
|
||||
}
|
||||
|
||||
this.logger.log(`SMS sent to ${masked} via proxy (serverId=${serverId})`);
|
||||
return { serverId };
|
||||
}
|
||||
|
||||
private async sendQuick(destination: string, message: string): Promise<SmsSendResult> {
|
||||
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
|
||||
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
|
||||
|
||||
Reference in New Issue
Block a user