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:
co-authored by
Cursor
parent
cf459807d8
commit
b1dcecc468
+211
-7
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user