Files
backend/src/auth/sms.service.ts
T
Alireza HassaniandCursor 88ef4e82a1 Add shipping cost groups and reject spam contact-form SMS.
Wire cart/checkout shipping fees with category groups and static rates, reject gibberish contact submissions, and SMS only valid Iranian mobiles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 21:52:41 +03:30

552 lines
17 KiB
TypeScript

import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export type SmsSendResult = {
serverId: string;
};
export type SmsBulkSendResult = {
serverIds: string[];
recipientCount: number;
};
export type SmsSendOptions = {
/** Gama shortcode override. When omitted, uses SMS_GAMA_SOURCE_SERVICE. */
source?: string;
};
/** Default Meshkee advertising shortcode when a business has no own SMS number. */
export const DEFAULT_ADVERTISING_SMS_SOURCE = '5000990009';
/** Gama batch size limit is undocumented; keep chunks conservative. */
const GAMA_BULK_CHUNK_SIZE = 100;
type GamaQuickResponse = {
success?: boolean;
message?: string | null;
errors?: unknown;
body?: number | string | null;
};
type GamaBulkResponse = {
success?: boolean;
message?: string | null;
errors?: unknown;
body?: Array<number | string> | null;
};
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly config: ConfigService) {}
isEnabled(): boolean {
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
}
/** Advertising / customer-broadcast fallback shortcode. */
getAdvertisingSource(): string {
return (
this.config.get<string>('SMS_GAMA_SOURCE_ADVERTISE')?.trim() ||
DEFAULT_ADVERTISING_SMS_SOURCE
);
}
/** Meshkee service shortcode (OTP + verified SMS templates). */
getServiceSource(): string {
return (
this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim() ||
'5000110005'
);
}
/**
* Best-effort account credit from Gama. Returns null when the provider
* does not expose credit over REST (check the Gama panel instead).
*/
async getAccountCredit(): Promise<{ balance: number; currency?: string } | null> {
if (!this.isEnabled() || this.isProxyConfigured()) {
return null;
}
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
const baseUrl = (
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
).replace(/\/$/, '');
if (!username || !password) return null;
// Gama REST docs do not document a credit endpoint; try common paths quietly.
for (const path of ['/account/credit', '/credit', '/user/credit']) {
try {
const response = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) continue;
const payload = (await response.json()) as {
success?: boolean;
body?: number | { credit?: number; balance?: number };
};
if (!payload.success) continue;
const raw =
typeof payload.body === 'number'
? payload.body
: payload.body?.credit ?? payload.body?.balance;
if (typeof raw === 'number' && Number.isFinite(raw)) {
return { balance: raw };
}
} catch {
// ignore and try next path
}
}
return null;
}
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})`,
);
return { serverId: 'disabled' };
}
const brand = businessNameFa?.trim();
const message = brand
? `کد تایید شما: ${code}\n${brand}`
: `کد تایید شما: ${code}`;
return this.sendMessage(cellNumber, message);
}
async sendMessage(
cellNumber: string,
message: string,
options?: SmsSendOptions,
): Promise<SmsSendResult> {
if (!this.isEnabled()) {
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
return { serverId: 'disabled' };
}
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');
}
const sourceOverride = options?.source?.trim() || undefined;
// Local proxy path has no per-sender shortcode unless we pass source through.
if (this.isProxyConfigured()) {
return this.sendViaProxy(destination, text, sourceOverride);
}
return this.sendQuick(destination, text, sourceOverride);
}
/**
* One message → many destinations via Gama SendBulk (`/send/bulk`).
* Falls back to sequential SendQuick / proxy when only one number or proxy is configured.
*/
async sendBulkMessage(
cellNumbers: string[],
message: string,
options?: SmsSendOptions,
): Promise<SmsBulkSendResult> {
if (!this.isEnabled()) {
this.logger.warn(
`SMS disabled — bulk message for ${cellNumbers.length} recipients not sent: ${message}`,
);
return { serverIds: ['disabled'], recipientCount: cellNumbers.length };
}
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 destinations: string[] = [];
const seen = new Set<string>();
for (const raw of cellNumbers) {
const msisdn = toGamaMsisdn(raw);
if (!msisdn || seen.has(msisdn)) continue;
seen.add(msisdn);
destinations.push(msisdn);
}
if (destinations.length === 0) {
throw new BadRequestException('No valid cellphone numbers to send to');
}
const sourceOverride = options?.source?.trim() || undefined;
if (destinations.length === 1) {
const single = await this.sendMessage(destinations[0], text, options);
return { serverIds: [single.serverId], recipientCount: 1 };
}
// Local proxy only supports single destination — send sequentially.
if (this.isProxyConfigured()) {
const serverIds: string[] = [];
for (const destination of destinations) {
const result = await this.sendViaProxy(destination, text, sourceOverride);
serverIds.push(result.serverId);
}
return { serverIds, recipientCount: destinations.length };
}
const serverIds: string[] = [];
for (let i = 0; i < destinations.length; i += GAMA_BULK_CHUNK_SIZE) {
const chunk = destinations.slice(i, i + GAMA_BULK_CHUNK_SIZE);
const chunkIds = await this.sendBulk(chunk, text, sourceOverride);
serverIds.push(...chunkIds);
}
return { serverIds, recipientCount: destinations.length };
}
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,
source?: 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,
...(source ? { source } : {}),
}),
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,
sourceOverride?: 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 =
sourceOverride?.trim() ||
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 source=${source} (serverId=${serverId})`,
);
return { serverId };
}
/** Gama SendBulk — one message, many MSISDNs (`destinations` array). */
private async sendBulk(
destinations: string[],
message: string,
sourceOverride?: string,
): Promise<string[]> {
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
const source =
sourceOverride?.trim() ||
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/bulk`;
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,
destinations,
delay: 0,
expire: null,
}),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
this.logger.error(
`Gama SendBulk network error for ${destinations.length} recipients: ${
err instanceof Error ? err.message : err
}`,
);
throw new ServiceUnavailableException('SMS provider is unreachable');
}
let payload: GamaBulkResponse;
try {
payload = (await response.json()) as GamaBulkResponse;
} catch {
this.logger.error(
`Gama SendBulk returned non-JSON for ${destinations.length} recipients (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 SendBulk failed for ${destinations.length} recipients: ${providerMessage}`,
);
throw mapGamaError(providerMessage);
}
const body = Array.isArray(payload.body) ? payload.body : [];
const serverIds = body.map((id) => String(id)).filter(Boolean);
if (serverIds.length === 0) {
this.logger.warn(
`Gama SendBulk succeeded without serverIds for ${destinations.length} recipients`,
);
throw new ServiceUnavailableException('SMS provider returned an empty server id');
}
this.logger.log(
`SMS bulk sent to ${destinations.length} via Gama source=${source} (ids=${serverIds.length})`,
);
return serverIds;
}
}
/** 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;
}
/**
* Iranian mobile only (local 09xxxxxxxxx → `989xxxxxxxxx`).
* Rejects landlines, foreign numbers, and invalid formats.
*/
export function toIranianMobileMsisdn(raw: string): string | null {
const msisdn = toGamaMsisdn(raw);
if (!msisdn || !/^989\d{9}$/.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');
}
if (lower.includes('maximum amount') || lower.includes('batch')) {
return new BadRequestException('Too many SMS destinations in one batch');
}
return new ServiceUnavailableException('SMS provider rejected the request');
}