Add partner SMS gateway via Gama SendQuick and document it.

Expose POST /public/sms/send with API key + domain allowlist for external backends like Balout, wire Meshkee OTP/message sends to Gama, and publish Partner SMS docs on /docs/website.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-04 11:09:21 +03:30
co-authored by Cursor
parent 267a218c26
commit f4295e780c
22 changed files with 803 additions and 16 deletions
+168 -11
View File
@@ -1,6 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export type SmsSendResult = {
serverId: string;
};
type GamaQuickResponse = {
success?: boolean;
message?: string | null;
errors?: unknown;
body?: number | string | null;
};
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
@@ -11,27 +27,168 @@ export class SmsService {
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
}
async sendVerificationCode(cellNumber: string, code: string): Promise<void> {
async sendVerificationCode(cellNumber: string, code: string): Promise<SmsSendResult> {
if (!this.isEnabled()) {
this.logger.warn(
`SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`,
);
return;
return { serverId: 'disabled' };
}
// TODO: integrate real SMS provider when API credentials are available
this.logger.log(`Sending SMS verification code to ${cellNumber}`);
throw new Error('SMS provider is not configured yet');
const message = `کد تایید شما: ${code}`;
return this.sendMessage(cellNumber, message);
}
async sendMessage(cellNumber: string, message: string): Promise<void> {
async sendMessage(cellNumber: string, message: string): Promise<SmsSendResult> {
if (!this.isEnabled()) {
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
return;
return { serverId: 'disabled' };
}
// TODO: integrate real SMS provider when API credentials are available
this.logger.log(`Sending SMS message to ${cellNumber}: ${message}`);
throw new Error('SMS provider is not configured yet');
const text = message.trim();
if (!text) {
throw new BadRequestException('Message is empty');
}
if (text.length > 700) {
throw new BadRequestException('Message is too long (max 700 characters)');
}
const destination = toGamaMsisdn(cellNumber);
if (!destination) {
throw new BadRequestException('Invalid cellphone number');
}
return this.sendQuick(destination, text);
}
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();
const source = this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
const baseUrl = (
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
).replace(/\/$/, '');
if (!username || !password || !source) {
this.logger.error('SMS provider credentials are not configured');
throw new ServiceUnavailableException('SMS provider is not configured yet');
}
const url = `${baseUrl}/send/quick`;
const masked = maskMsisdn(destination);
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
message,
source,
destination,
delay: 0,
expire: null,
}),
signal: AbortSignal.timeout(15_000),
});
} catch (err) {
this.logger.error(
`Gama SendQuick network error for ${masked}: ${err instanceof Error ? err.message : err}`,
);
throw new ServiceUnavailableException('SMS provider is unreachable');
}
let payload: GamaQuickResponse;
try {
payload = (await response.json()) as GamaQuickResponse;
} catch {
this.logger.error(`Gama SendQuick 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(`Gama SendQuick failed for ${masked}: ${providerMessage}`);
throw mapGamaError(providerMessage);
}
const serverId = String(payload.body ?? '');
if (!serverId) {
this.logger.warn(`Gama SendQuick succeeded without serverId for ${masked}`);
throw new ServiceUnavailableException('SMS provider returned an empty server id');
}
this.logger.log(`SMS sent to ${masked} via Gama (serverId=${serverId})`);
return { serverId };
}
}
/** Accept 09… / 9… / +989… / 989… → Gama MSISDN `989…` (no plus). */
export function toGamaMsisdn(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const digits = trimmed.replace(/\D/g, '');
if (!digits) return null;
let msisdn = digits;
if (msisdn.startsWith('0') && msisdn.length === 11) {
msisdn = `98${msisdn.slice(1)}`;
} else if (msisdn.length === 10 && msisdn.startsWith('9')) {
msisdn = `98${msisdn}`;
} else if (msisdn.startsWith('98') && msisdn.length === 12) {
// already MSISDN
} else if (trimmed.startsWith('+') && digits.length >= 10 && digits.length <= 15) {
msisdn = digits;
} else {
return null;
}
if (!/^98\d{10}$/.test(msisdn)) {
return null;
}
return msisdn;
}
export function maskMsisdn(msisdn: string): string {
if (msisdn.length < 6) return '***';
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
}
function mapGamaError(message: string): Error {
const lower = message.toLowerCase();
if (
lower.includes('authentication failed') ||
lower.includes('access denied') ||
lower.includes('inactive user')
) {
return new ServiceUnavailableException('SMS provider authentication failed');
}
if (
lower.includes('credit is not enough') ||
lower.includes('expired account')
) {
return new ServiceUnavailableException('SMS provider credit or account issue');
}
if (
lower.includes('cellphone') ||
lower.includes('valid cellphone') ||
lower.includes('invalid orig') ||
lower.includes('arguments')
) {
return new BadRequestException('Invalid SMS destination or sender');
}
if (lower.includes('message is empty')) {
return new BadRequestException('Message is empty');
}
return new ServiceUnavailableException('SMS provider rejected the request');
}