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:
Alireza Hassani
2026-08-08 23:29:56 +03:30
co-authored by Cursor
parent 77f3cb2a67
commit 8f05ee2b58
14 changed files with 385 additions and 14 deletions
+85 -2
View File
@@ -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();