Add customer Excel import/export and Gama bulk SMS sending.

Business SMS uses optional settings.sms.senderNumber (else advertising shortcode) with SendQuick for one recipient and SendBulk for quantity.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-08 14:12:48 +03:30
co-authored by Cursor
parent cf459807d8
commit b1dcecc468
13 changed files with 1109 additions and 12 deletions
+211 -7
View File
@@ -10,6 +10,22 @@ 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;
@@ -17,6 +33,13 @@ type GamaQuickResponse = {
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);
@@ -27,6 +50,14 @@ export class SmsService {
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
);
}
async sendVerificationCode(
cellNumber: string,
code: string,
@@ -46,7 +77,11 @@ export class SmsService {
return this.sendMessage(cellNumber, message);
}
async sendMessage(cellNumber: string, message: string): Promise<SmsSendResult> {
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' };
@@ -65,11 +100,78 @@ export class SmsService {
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);
return this.sendViaProxy(destination, text, sourceOverride);
}
return this.sendQuick(destination, text);
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 {
@@ -80,7 +182,11 @@ export class SmsService {
}
/** Local-dev path: deliver via remote Meshkee partner SMS gateway (production reaches Gama). */
private async sendViaProxy(destination: string, message: string): Promise<SmsSendResult> {
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();
@@ -99,6 +205,7 @@ export class SmsService {
domain,
to: destination,
message,
...(source ? { source } : {}),
}),
signal: AbortSignal.timeout(20_000),
});
@@ -144,10 +251,16 @@ export class SmsService {
return { serverId };
}
private async sendQuick(destination: string, message: string): Promise<SmsSendResult> {
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 = this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.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(/\/$/, '');
@@ -209,9 +322,97 @@ export class SmsService {
throw new ServiceUnavailableException('SMS provider returned an empty server id');
}
this.logger.log(`SMS sent to ${masked} via Gama (serverId=${serverId})`);
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). */
@@ -273,5 +474,8 @@ function mapGamaError(message: string): Error {
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');
}