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>
552 lines
17 KiB
TypeScript
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');
|
|
}
|