canina/backend/src/common/services/sms.service.ts

1390 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger } from '@nestjs/common';
import * as https from 'https';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma } from '@prisma/client';
import {
DEFAULT_SMS_RULES,
SMS_EVENT_DEFINITIONS,
SmsEventDefinition,
} from './sms-events.constants';
export interface SendPatternSmsOptions {
to: string;
bodyId: number; // MeliPayamak Shared Pattern Body ID
args: string[]; // Dynamic variables inside pattern
type?: string; // OTP, ORDER_CONFIRMATION, SHIPPING_TRACKING, B2B_NOTIFICATION, PET_CARE_REMINDER, TEST, GENERIC
}
export interface SmsRule {
id: string;
title: string;
event: string; // ORDER_PAID, ORDER_CREATED, ORDER_SHIPPED, B2B_SUBMITTED, CONTACT_SUBMITTED, OTP
enabled: boolean;
recipientType: 'customer' | 'admin' | 'custom';
customPhone?: string;
patternId: number;
variables: string[]; // Ordered parameter keys: e.g. ['orderNumber', 'recipientName', 'address']
description?: string;
}
export interface SmsConfig {
enabled: boolean;
username: string;
password: string;
fromNumber?: string;
adminPhone?: string;
otpBodyId: number;
orderBodyId: number;
shippingBodyId: number;
b2bBodyId: number;
petCareBodyId: number;
rules?: SmsRule[];
}
export interface MeliPayamakPattern {
id: number;
title: string;
body: string;
status: number; // 0 = Pending, 1 = Approved, 2 = Needs Edit
statusText?: string;
assignedTo?: string[];
}
export class SmsLogQuery {
page?: number;
limit?: number;
search?: string;
type?: string;
status?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface MeliPayamakResponse {
Value?: number;
RetStatus?: number;
StrRetStatus?: string;
}
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Get active SMS configuration from database (fallback to .env)
*/
async getSmsConfig(): Promise<SmsConfig> {
try {
const setting = await this.prisma.setting.findFirst({
where: { category: 'sms' },
});
const dbConfig = (setting?.value as Record<string, unknown>) || {};
return {
enabled:
dbConfig.enabled !== undefined ? Boolean(dbConfig.enabled) : true,
username:
typeof dbConfig.username === 'string' && dbConfig.username
? dbConfig.username
: process.env.MELIPAYAMAK_USERNAME || '9364100228',
password:
typeof dbConfig.password === 'string' && dbConfig.password
? dbConfig.password
: process.env.MELIPAYAMAK_PASSWORD || '',
fromNumber:
typeof dbConfig.fromNumber === 'string' && dbConfig.fromNumber
? dbConfig.fromNumber
: process.env.MELIPAYAMAK_FROM_NUMBER || '',
adminPhone:
typeof dbConfig.adminPhone === 'string' && dbConfig.adminPhone
? dbConfig.adminPhone
: process.env.ADMIN_MOBILE || '09364100228',
otpBodyId: Number(
dbConfig.otpBodyId || process.env.MELIPAYAMAK_OTP_BODY_ID || '508079',
),
orderBodyId: Number(
dbConfig.orderBodyId ||
process.env.MELIPAYAMAK_ORDER_BODY_ID ||
'508081',
),
shippingBodyId: Number(
dbConfig.shippingBodyId ||
process.env.MELIPAYAMAK_SHIPPING_BODY_ID ||
'508082',
),
b2bBodyId: Number(
dbConfig.b2bBodyId || process.env.MELIPAYAMAK_B2B_BODY_ID || '508083',
),
petCareBodyId: Number(
dbConfig.petCareBodyId ||
process.env.MELIPAYAMAK_PET_CARE_BODY_ID ||
'0',
),
rules: Array.isArray(dbConfig.rules)
? (dbConfig.rules as SmsRule[])
: DEFAULT_SMS_RULES,
};
} catch {
return {
enabled: true,
username: process.env.MELIPAYAMAK_USERNAME || '9364100228',
password: process.env.MELIPAYAMAK_PASSWORD || '',
fromNumber: process.env.MELIPAYAMAK_FROM_NUMBER || '',
adminPhone: process.env.ADMIN_MOBILE || '09364100228',
otpBodyId: Number(process.env.MELIPAYAMAK_OTP_BODY_ID || '508079'),
orderBodyId: Number(process.env.MELIPAYAMAK_ORDER_BODY_ID || '508081'),
shippingBodyId: Number(
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '508082',
),
b2bBodyId: Number(process.env.MELIPAYAMAK_B2B_BODY_ID || '508083'),
petCareBodyId: Number(process.env.MELIPAYAMAK_PET_CARE_BODY_ID || '0'),
rules: DEFAULT_SMS_RULES,
};
}
}
/**
* Dispatch dynamic SMS notification rules for an event
*/
async triggerEvent(
event: string,
data: Record<string, any>,
): Promise<boolean> {
try {
const config = await this.getSmsConfig();
if (!config.enabled) {
this.logger.log(
`[SMS Trigger] SMS gateway disabled. Skipping event ${event}`,
);
return false;
}
const activeRules = (config.rules || []).filter(
(rule) =>
rule.enabled && rule.event === event && Number(rule.patternId) > 0,
);
if (activeRules.length > 0) {
this.logger.log(
`[SMS Trigger] Found ${activeRules.length} active rule(s) for event ${event}`,
);
for (const rule of activeRules) {
const phones: string[] = [];
if (rule.recipientType === 'customer') {
const customerPhone =
data.customerPhone ||
data.phone ||
data.mobile ||
data.recipientPhone;
if (customerPhone) phones.push(String(customerPhone).trim());
} else if (rule.recipientType === 'admin') {
const rawAdminPhones =
config.adminPhone ||
process.env.ADMIN_MOBILE ||
process.env.ADMIN_MOBILE_ALERT ||
'';
const list = rawAdminPhones
.split(/[,;\s]+/)
.map((p) => p.trim())
.filter(Boolean);
if (list.length > 0) {
phones.push(...list);
}
} else if (rule.recipientType === 'custom' && rule.customPhone) {
const list = rule.customPhone
.split(/[,;\s]+/)
.map((p) => p.trim())
.filter(Boolean);
if (list.length > 0) {
phones.push(...list);
}
}
if (phones.length === 0) {
this.logger.warn(
`[SMS Trigger] Rule "${rule.title}" has no recipient phone number.`,
);
continue;
}
const args: string[] = (rule.variables || []).map((varKey) => {
const val = data[varKey];
if (val === undefined || val === null) return '';
return String(val).trim();
});
for (const targetPhone of phones) {
this.sendPatternSms({
to: targetPhone,
bodyId: Number(rule.patternId),
args,
type: `${event}_${rule.recipientType.toUpperCase()}`,
}).catch((err) => {
this.logger.error(
`[SMS Trigger Error] Failed to execute rule "${rule.title}" to ${targetPhone}: ${err}`,
);
});
}
}
return true;
}
// Legacy fallback when no rules are configured for this event
this.logger.log(
`[SMS Trigger] No custom rules configured for ${event}. Using standard fallback.`,
);
if (event === 'ORDER_PAID' || event === 'ORDER_CREATED') {
const phone = data.customerPhone || data.phone || data.mobile;
if (phone && config.orderBodyId) {
await this.sendOrderConfirmation(
phone,
data.customerName || 'مشتری',
data.orderNumber || '',
data.amount || '',
);
}
} else if (event === 'ORDER_SHIPPED') {
const phone = data.customerPhone || data.phone || data.mobile;
if (phone && config.shippingBodyId) {
await this.sendShippingNotification(
phone,
data.orderNumber || '',
data.trackingCode || '',
);
}
} else if (event === 'B2B_SUBMITTED') {
const phone = data.applicantPhone || data.phone;
if (phone && config.b2bBodyId) {
await this.sendB2bNotification(phone, data.applicantName || '');
}
}
return true;
} catch (err: any) {
this.logger.error(
`[SMS Trigger Exception] Error triggering event ${event}: ${err?.message || err}`,
);
return false;
}
}
/**
* Get all supported SMS events and their dynamic variables
*/
getEventDefinitions(): SmsEventDefinition[] {
return SMS_EVENT_DEFINITIONS;
}
/**
* Test an SMS rule with sample/mock event data
*/
async testRule(
rule: SmsRule,
testPhone?: string,
): Promise<{
success: boolean;
message: string;
targetPhone?: string;
args?: string[];
rawResponse?: any;
}> {
const config = await this.getSmsConfig();
if (!config.username || !config.password) {
return {
success: false,
message:
'مشخصات نام کاربری و رمز عبور سامانه ملی پیامک تنظیم نشده است.',
};
}
if (!rule.patternId) {
return {
success: false,
message: 'کد پترن (Body ID) برای این سناریو مشخص نشده است.',
};
}
// Determine target recipient phone
let phone = testPhone ? testPhone.trim() : '';
if (!phone) {
if (rule.recipientType === 'customer') {
phone = config.adminPhone || '09123456789';
} else if (rule.recipientType === 'admin') {
phone = config.adminPhone || process.env.ADMIN_MOBILE || '09123456789';
} else if (rule.recipientType === 'custom') {
phone = (rule.customPhone || '').split(/[,;\s]+/)[0] || '';
}
}
if (!phone) {
return {
success: false,
message:
'شماره گیرنده جهت ارسال تست یافت نشد. لطفاً یک شماره موبایل وارد کنید.',
};
}
// Resolve sample values for variables
const eventDef = SMS_EVENT_DEFINITIONS.find((d) => d.event === rule.event);
const sampleData: Record<string, string> = {};
if (eventDef) {
for (const v of eventDef.variables) {
sampleData[v.key] = v.sample;
}
}
const args = (rule.variables || []).map((varKey) => {
return sampleData[varKey] || varKey;
});
const result = await this.testSms(phone, Number(rule.patternId), args);
return {
success: result.success,
message: result.message,
targetPhone: phone,
args,
rawResponse: result.rawResponse,
};
}
/**
* Helper HTTP POST request to Payamak-Panel ASMX endpoints
*/
private async postAsmx(
endpoint: string,
params: Record<string, string | number>,
): Promise<string> {
const postData = Object.entries(params)
.map(
([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`,
)
.join('&');
return new Promise((resolve, reject) => {
const req = https.request(
`https://api.payamak-panel.com/post/SharedService.asmx/${endpoint}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
},
},
(res) => {
let data = '';
res.on('data', (chunk: Buffer | string) => (data += String(chunk)));
res.on('end', () => resolve(data));
},
);
req.on('error', (err: Error) => reject(err));
req.write(postData);
req.end();
});
}
/**
* Helper to write an SMS Log to PostgreSQL safely
*/
private async recordLog(entry: {
receptor: string;
type: string;
patternId?: number;
args?: string[];
messageText?: string;
status: string;
recId?: string;
errorMessage?: string;
}): Promise<void> {
try {
await this.prisma.smsLog.create({
data: {
receptor: entry.receptor,
type: entry.type || 'GENERIC',
patternId: entry.patternId || null,
args: entry.args || [],
messageText: entry.messageText || null,
status: entry.status,
recId: entry.recId ? String(entry.recId) : null,
errorMessage: entry.errorMessage || null,
},
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`[SMS Log Save Failed]: ${msg}`);
}
}
/**
* Query paginated SMS Logs with Search, Filter and Sorting
*/
async getSmsLogs(query: SmsLogQuery = {}) {
const page = Math.max(1, Number(query.page) || 1);
const limit = Math.max(1, Math.min(100, Number(query.limit) || 15));
const skip = (page - 1) * limit;
const where: Prisma.SmsLogWhereInput = {};
if (query.search) {
const s = query.search.trim();
where.OR = [
{ receptor: { contains: s, mode: 'insensitive' } },
{ recId: { contains: s, mode: 'insensitive' } },
{ errorMessage: { contains: s, mode: 'insensitive' } },
];
}
if (query.type && query.type !== 'ALL') {
where.type = query.type;
}
if (query.status && query.status !== 'ALL') {
where.status = query.status;
}
const sortField = query.sortBy || 'createdAt';
const sortOrder = query.sortOrder === 'asc' ? 'asc' : 'desc';
const [logs, total, totalSuccess, totalFailed] = await Promise.all([
this.prisma.smsLog.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: sortOrder },
}),
this.prisma.smsLog.count({ where }),
this.prisma.smsLog.count({ where: { status: 'SUCCESS' } }),
this.prisma.smsLog.count({ where: { status: 'FAILED' } }),
]);
return {
logs,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
stats: {
total,
success: totalSuccess,
failed: totalFailed,
successRate: total > 0 ? Math.round((totalSuccess / total) * 100) : 100,
},
};
}
/**
* Delete specific SMS log entry
*/
async deleteSmsLog(id: string) {
return this.prisma.smsLog.delete({ where: { id } });
}
/**
* Clear all SMS logs
*/
async clearAllSmsLogs() {
return this.prisma.smsLog.deleteMany();
}
/**
* Parse XML response for GetSharedServiceBody into structured pattern array
*/
private parsePatternsXml(xml: string): MeliPayamakPattern[] {
const patterns: MeliPayamakPattern[] = [];
// MeliPayamak uses <ShareServiceBody> and child tags <BodyID>, <Title>, <Body>, <BodyStatus>
// We match both <ShareServiceBody> and <SharedServiceBody> for backwards/cross compatibility
const itemRegex =
/<(?:Share|Shared)ServiceBody>([\s\S]*?)<\/(?:Share|Shared)ServiceBody>/gi;
let match: RegExpExecArray | null;
while ((match = itemRegex.exec(xml)) !== null) {
const block = match[1];
const idMatch = block.match(/<(?:BodyID|Id)>(\d+)<\/(?:BodyID|Id)>/i);
const titleMatch = block.match(/<Title>([\s\S]*?)<\/Title>/i);
const bodyMatch = block.match(/<Body>([\s\S]*?)<\/Body>/i);
const statusMatch = block.match(
/<(?:BodyStatus|Status)>(-?\d+)<\/(?:BodyStatus|Status)>/i,
);
if (idMatch) {
const id = parseInt(idMatch[1], 10);
const title = (titleMatch ? titleMatch[1] : '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.trim();
const body = (bodyMatch ? bodyMatch[1] : '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.trim();
const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
let statusText = 'در انتظار تایید';
if (status === 1) statusText = 'تایید شده';
else if (status === 2 || status === -1) statusText = 'نیاز به ویرایش';
patterns.push({ id, title, body, status, statusText });
}
}
return patterns;
}
/**
* Fetch remaining SMS balance/credit (in Rials or count) from MeliPayamak
*/
async getCredit(): Promise<{
success: boolean;
credit: number;
message?: string;
}> {
const config = await this.getSmsConfig();
if (!config.username || !config.password) {
return {
success: false,
credit: 0,
message: 'نام کاربری و کلمه عبور ملی پیامک تنظیم نشده است.',
};
}
return new Promise((resolve) => {
const payload = JSON.stringify({
username: config.username,
password: config.password,
});
const req = https.request(
'https://rest.payamak-panel.com/api/SendSMS/GetCredit',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
(res) => {
let data = '';
res.on('data', (chunk: Buffer | string) => (data += String(chunk)));
res.on('end', () => {
try {
const json = JSON.parse(data);
const val = Number(json.Value ?? json.value ?? 0);
resolve({
success: true,
credit: val,
message: `اعتبار با موفقیت دریافت شد: ${val}`,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
resolve({
success: false,
credit: 0,
message: `خطای دریافت اعتبار: ${data || msg}`,
});
}
});
},
);
req.on('error', (err: Error) => {
resolve({
success: false,
credit: 0,
message: `خطای برقراری ارتباط با وب‌سرویس ملی پیامک: ${err.message}`,
});
});
req.write(payload);
req.end();
});
}
/**
* Render interpolated message text using pattern body template and arguments
*/
async renderPatternMessage(
patternId?: number | null,
args: string[] = [],
): Promise<string> {
if (!patternId) {
return args.join(' ');
}
try {
const patterns = await this.getPatterns();
const target = patterns.find((p) => p.id === patternId);
if (!target || !target.body) {
return args.length > 0
? `الگو ${patternId} با مقادیر: ${args.join(' ، ')}`
: `الگو ${patternId}`;
}
let rendered = target.body;
args.forEach((val, idx) => {
const regex = new RegExp(`\\{${idx}\\}`, 'g');
rendered = rendered.replace(regex, val || '');
});
return rendered;
} catch {
return args.length > 0
? `الگو ${patternId} با مقادیر: ${args.join(' ، ')}`
: `الگو ${patternId}`;
}
}
/**
* Fetch all registered patterns from MeliPayamak (with local cache sync)
*/
async getPatterns(): Promise<MeliPayamakPattern[]> {
const config = await this.getSmsConfig();
let remotePatterns: MeliPayamakPattern[] = [];
if (config.username && config.password) {
try {
const rawXml = await this.postAsmx('GetSharedServiceBody', {
username: config.username,
password: config.password,
});
remotePatterns = this.parsePatternsXml(rawXml);
if (remotePatterns.length > 0) {
// Cache patterns asynchronously in database
this.prisma.setting
.upsert({
where: { key: 'sms_patterns_cache' },
update: { value: remotePatterns as any },
create: {
key: 'sms_patterns_cache',
category: 'sms_patterns',
value: remotePatterns as any,
},
})
.catch((err) => {
this.logger.warn(`Failed to cache SMS patterns: ${err.message}`);
});
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`[SMS Patterns] Remote fetch failed: ${msg}. Using stored patterns.`,
);
}
}
// Load cached patterns from settings table
const storedSetting = await this.prisma.setting.findFirst({
where: {
category: 'sms_patterns',
key: { in: ['sms_patterns_cache', 'sms_patterns_config'] },
},
orderBy: { updatedAt: 'desc' },
});
const cachedPatterns: MeliPayamakPattern[] = Array.isArray(
storedSetting?.value,
)
? (storedSetting.value as unknown as MeliPayamakPattern[])
: [];
// Prioritize remote patterns, then cached patterns, then fallback defaults only if completely empty
const map = new Map<number, MeliPayamakPattern>();
// 1. If we have remote patterns, use them
for (const rp of remotePatterns) {
if (rp && rp.id) {
map.set(rp.id, { ...rp });
}
}
// 2. If remote returned empty or offline, use cached patterns
if (map.size === 0) {
for (const cp of cachedPatterns) {
if (cp && cp.id) {
map.set(cp.id, { ...cp });
}
}
}
// 3. Only if map is still completely empty (brand new install / unconfigured account), provide fallback defaults
if (map.size === 0) {
const defaultConfigs: Array<{
id: number;
title: string;
body: string;
}> = [
{
id: config.otpBodyId,
title: 'کد تایید ورود و ثبت‌نام (OTP)',
body: 'کد ورود به کنینا: {0}',
},
{
id: config.orderBodyId,
title: 'تایید و ثبت فاکتور سفارش',
body: '{0} عزیز ، سفارش شما به شماره {1} به مبلغ {2} با موفقیت ثبت شد.\nکنینا ایران',
},
{
id: config.shippingBodyId,
title: 'ارسال کد رهگیری پستی',
body: 'مرسوله سفارش {0} با کد رهگیری پستی {1} ارسال شد.',
},
{
id: config.b2bBodyId,
title: 'اطلاع‌رسانی درخواست B2B',
body: 'همکار گرامی {0} درخواست شما دریافت شد.',
},
];
for (const d of defaultConfigs) {
if (d.id > 0) {
map.set(d.id, {
id: d.id,
title: d.title,
body: d.body,
status: 1,
statusText: 'تایید شده',
assignedTo: [],
});
}
}
}
// Bind current scenario triggers and system assignments
const result = Array.from(map.values()).map((p) => {
const assigned: string[] = [];
// Check dynamic scenario rules (Primary Assignment)
if (Array.isArray(config.rules)) {
for (const r of config.rules) {
if (Number(r.patternId) === p.id && r.enabled) {
const ruleTag = `سناریو: ${r.title}`;
if (!assigned.includes(ruleTag)) {
assigned.push(ruleTag);
}
}
}
}
// Check legacy default bindings
if (
p.id === config.otpBodyId &&
!assigned.some((a) => a.includes('OTP'))
) {
assigned.push('کد تایید OTP');
}
if (
p.id === config.orderBodyId &&
!assigned.some((a) => a.includes('سفارش'))
) {
assigned.push('پترن پیش‌فرض سفارش');
}
if (
p.id === config.shippingBodyId &&
!assigned.some((a) => a.includes('پست'))
) {
assigned.push('پترن پیش‌فرض پست');
}
if (
p.id === config.b2bBodyId &&
!assigned.some((a) => a.includes('B2B'))
) {
assigned.push('پترن پیش‌فرض B2B');
}
if (
p.id === config.petCareBodyId &&
!assigned.some((a) => a.includes('پت'))
) {
assigned.push('پترن پیش‌فرض پرونده پت');
}
return { ...p, assignedTo: assigned };
});
return result;
}
/**
* Add a new pattern to MeliPayamak
*/
async addPattern(
title: string,
body: string,
blackListId: number = 0,
): Promise<{ success: boolean; bodyId?: number; message: string }> {
const config = await this.getSmsConfig();
if (!config.username || !config.password) {
return {
success: false,
message: 'مشخصات نام کاربری و رمز عبور سامانه پیامک تنظیم نشده است.',
};
}
try {
const rawXml = await this.postAsmx('SharedServiceBodyAdd', {
username: config.username,
password: config.password,
title,
body,
blackListId,
});
const match =
rawXml.match(/<int.*?>(-?\d+)<\/int>/i) || rawXml.match(/>(-?\d+)</);
const code = match ? parseInt(match[1], 10) : 0;
if (code > 0) {
await this.saveLocalPattern({
id: code,
title,
body,
status: 0,
statusText: 'در انتظار تایید',
});
return {
success: true,
bodyId: code,
message: `الگو با موفقیت ثبت شد و شناسه اختصاصی ${code} دریافت گردید. (وضعیت: در انتظار تایید ناظر)`,
};
} else if (code === 0) {
return {
success: false,
message: 'نام کاربری یا کلمه عبور ملی پیامک اشتباه است.',
};
} else if (code === -2) {
return {
success: false,
message: 'شناسه لیست سیاه ویژه اشتباه است.',
};
} else {
return {
success: false,
message: `خطا در ثبت پترن با کد پاسخ: ${code}`,
};
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
const generatedId = Math.floor(500000 + Math.random() * 90000);
await this.saveLocalPattern({
id: generatedId,
title,
body,
status: 0,
statusText: 'ثبت محلی (در انتظار اتصال سرور)',
});
return {
success: true,
bodyId: generatedId,
message: `الگو به صورت محلی با شناسه موقت ${generatedId} ذخیره شد (${msg}).`,
};
}
}
/**
* Edit an existing pattern in MeliPayamak
*/
async editPattern(
bodyId: number,
body: string,
): Promise<{ success: boolean; message: string }> {
const config = await this.getSmsConfig();
if (!config.username || !config.password) {
return {
success: false,
message: 'مشخصات نام کاربری و رمز عبور سامانه پیامک تنظیم نشده است.',
};
}
try {
const rawXml = await this.postAsmx('SharedServiceBodyEdit', {
username: config.username,
password: config.password,
bodyId,
body,
});
const match =
rawXml.match(/<int.*?>(-?\d+)<\/int>/i) || rawXml.match(/>(-?\d+)</);
const code = match ? parseInt(match[1], 10) : 0;
if (code === 1) {
await this.updateLocalPattern(bodyId, body);
return {
success: true,
message:
'ویرایش الگو با موفقیت انجام شد و منتظر تایید مجدد ناظر می‌باشد.',
};
} else if (code === -1) {
await this.updateLocalPattern(bodyId, body);
return {
success: true,
message:
'متن الگو در سیستم بروز شد (توجه: فقط الگوهای در وضعیت نیاز به ویرایش در وب‌سرویس اصلاح می‌شوند).',
};
} else {
return {
success: false,
message: `خطا در ویرایش الگو با کد پاسخ: ${code}`,
};
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
await this.updateLocalPattern(bodyId, body);
return {
success: true,
message: `متن الگو در دیتابیس داخلی بروز شد (${msg}).`,
};
}
}
private async saveLocalPattern(pattern: MeliPayamakPattern) {
try {
const setting = await this.prisma.setting.findFirst({
where: { category: 'sms_patterns' },
});
const list: MeliPayamakPattern[] = Array.isArray(setting?.value)
? (setting.value as unknown as MeliPayamakPattern[])
: [];
const updated = [pattern, ...list.filter((p) => p.id !== pattern.id)];
await this.prisma.setting.upsert({
where: { key: 'sms_patterns_config' },
update: {
category: 'sms_patterns',
value: updated as unknown as Prisma.InputJsonValue,
},
create: {
key: 'sms_patterns_config',
category: 'sms_patterns',
value: updated as unknown as Prisma.InputJsonValue,
},
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to save local pattern: ${msg}`);
}
}
private async updateLocalPattern(bodyId: number, newBody: string) {
try {
const setting = await this.prisma.setting.findFirst({
where: { category: 'sms_patterns' },
});
const list: MeliPayamakPattern[] = Array.isArray(setting?.value)
? (setting.value as unknown as MeliPayamakPattern[])
: [];
const updated = list.map((p) =>
p.id === bodyId
? {
...p,
body: newBody,
status: 0,
statusText: 'در انتظار تایید',
}
: p,
);
await this.prisma.setting.upsert({
where: { key: 'sms_patterns_config' },
update: {
category: 'sms_patterns',
value: updated as unknown as Prisma.InputJsonValue,
},
create: {
key: 'sms_patterns_config',
category: 'sms_patterns',
value: updated as unknown as Prisma.InputJsonValue,
},
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to update local pattern: ${msg}`);
}
}
/**
* Send Pattern SMS using MeliPayamak Shared Service Line (Bypasses Blacklist)
*/
async sendPatternSms(options: SendPatternSmsOptions): Promise<boolean> {
const config = await this.getSmsConfig();
const msgType = options.type || 'GENERIC_PATTERN';
if (!config.enabled) {
this.logger.warn(`[SMS Disabled] SMS dispatch skipped for ${options.to}`);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
status: 'DISABLED',
errorMessage: 'ارسال پیامک از پنل ادمین غیرفعال شده است.',
});
return false;
}
if (!config.username || !config.password) {
const err = 'مشخصات نام کاربری و رمز عبور سامانه پیامک تنظیم نشده است.';
this.logger.error(`[SMS MISCONFIGURED] ${err}`);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
status: 'FAILED',
errorMessage: err,
});
return false;
}
return new Promise((resolve) => {
const payload = JSON.stringify({
username: config.username,
password: config.password,
text: options.args.join(';'),
to: options.to,
bodyId: options.bodyId,
});
// Pre-render message text for logging
void this.renderPatternMessage(options.bodyId, options.args).then(
(renderedText) => {
const req = https.request(
'https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
(res) => {
let data = '';
res.on(
'data',
(chunk: Buffer | string) => (data += String(chunk)),
);
res.on('end', () => {
void (async () => {
try {
const json = JSON.parse(data) as MeliPayamakResponse;
const val = json.Value ?? 0;
if (json && (val > 15 || json.RetStatus === 1)) {
this.logger.log(
`[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${val})`,
);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
messageText: renderedText,
status: 'SUCCESS',
recId: String(val),
});
resolve(true);
} else {
const errMsg = `خطای درگاه ملی پیامک با کد بازگشتی: ${val}`;
this.logger.error(
`[SMS Error] Failed sending to ${options.to}. Code: ${val}`,
);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
messageText: renderedText,
status: 'FAILED',
recId: String(val),
errorMessage: errMsg,
});
resolve(false);
}
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : String(err);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
messageText: renderedText,
status: 'FAILED',
errorMessage: `خطای پارس پاسخ سرور: ${data || msg}`,
});
resolve(false);
}
})();
});
},
);
req.on('error', (err: Error) => {
void (async () => {
this.logger.error(
`[SMS Exception] MeliPayamak HTTP Error: ${err.message}`,
);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
messageText: renderedText,
status: 'FAILED',
errorMessage: `خطای شبکه: ${err.message}`,
});
resolve(false);
})();
});
req.write(payload);
req.end();
},
);
});
}
/**
* Test SMS Dispatch from Admin Panel
*/
async testSms(
targetPhone: string,
testPatternId?: number,
testArgs?: string[],
): Promise<{
success: boolean;
message: string;
rawResponse?: MeliPayamakResponse;
}> {
const config = await this.getSmsConfig();
if (!config.username || !config.password) {
const msg = 'نام کاربری یا رمز عبور سامانه ملی پیامک تنظیم نشده است.';
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: testPatternId,
args: testArgs,
status: 'FAILED',
errorMessage: msg,
});
return { success: false, message: msg };
}
const bodyId = testPatternId || config.otpBodyId || 508079;
const args = testArgs && testArgs.length > 0 ? testArgs : ['123456'];
return new Promise((resolve) => {
const payload = JSON.stringify({
username: config.username,
password: config.password,
text: args.join(';'),
to: targetPhone,
bodyId: bodyId,
});
void this.renderPatternMessage(bodyId, args).then((renderedText) => {
const req = https.request(
'https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
(res) => {
let data = '';
res.on('data', (chunk: Buffer | string) => (data += String(chunk)));
res.on('end', () => {
void (async () => {
try {
const json = JSON.parse(data) as MeliPayamakResponse;
const val = json.Value ?? 0;
if (json && (val > 15 || json.RetStatus === 1)) {
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
messageText: renderedText,
status: 'SUCCESS',
recId: String(val),
});
resolve({
success: true,
message: `پیامک تستی پترن با موفقیت ارسال شد (شناسه پیگیری ملی پیامک: ${val})`,
rawResponse: json,
});
} else {
const errMsg = `خطای درگاه ملی پیامک: کد پاسخ بازگشتی ${val}`;
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
messageText: renderedText,
status: 'FAILED',
recId: String(val),
errorMessage: errMsg,
});
resolve({
success: false,
message: errMsg,
rawResponse: json,
});
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
const errMsg = `پاسخ نامعتبر از سرور ملی پیامک: ${data || msg}`;
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
messageText: renderedText,
status: 'FAILED',
errorMessage: errMsg,
});
resolve({ success: false, message: errMsg });
}
})();
});
},
);
req.on('error', (err: Error) => {
void (async () => {
const errMsg = `خطای برقراری ارتباط با وب‌سرویس ملی پیامک: ${err.message}`;
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
messageText: renderedText,
status: 'FAILED',
errorMessage: errMsg,
});
resolve({ success: false, message: errMsg });
})();
});
req.write(payload);
req.end();
});
});
}
/**
* Send OTP Verification Code
*/
async sendOtp(phone: string, otpCode: string): Promise<boolean> {
const config = await this.getSmsConfig();
return this.sendPatternSms({
to: phone,
bodyId: config.otpBodyId,
args: [otpCode],
type: 'OTP',
});
}
/**
* Send Order Confirmation SMS
*/
async sendOrderConfirmation(
phone: string,
customerName: string,
orderNumber: string,
amount: string,
): Promise<boolean> {
const config = await this.getSmsConfig();
const cleanName = (customerName || 'مشتری').trim();
return this.sendPatternSms({
to: phone,
bodyId: config.orderBodyId,
args: [cleanName, orderNumber, amount],
type: 'ORDER_CONFIRMATION',
});
}
/**
* Send Shipping Status SMS with Tracking Code
*/
async sendShippingNotification(
phone: string,
orderNumber: string,
trackingCode: string,
): Promise<boolean> {
const config = await this.getSmsConfig();
return this.sendPatternSms({
to: phone,
bodyId: config.shippingBodyId,
args: [orderNumber, trackingCode],
type: 'SHIPPING_TRACKING',
});
}
/**
* Send B2B Application Notification SMS
*/
async sendB2bNotification(
phone: string,
applicantName: string,
): Promise<boolean> {
const config = await this.getSmsConfig();
return this.sendPatternSms({
to: phone,
bodyId: config.b2bBodyId,
args: [applicantName],
type: 'B2B_NOTIFICATION',
});
}
/**
* Send Pet Care Vaccination / Deworming Reminder SMS
*/
async sendPetCareReminder(
phone: string,
petName: string,
reminderType: string,
): Promise<boolean> {
const config = await this.getSmsConfig();
if (!config.petCareBodyId) return false;
return this.sendPatternSms({
to: phone,
bodyId: config.petCareBodyId,
args: [petName, reminderType],
type: 'PET_CARE_REMINDER',
});
}
/**
* Generic text SMS dispatch
*/
async sendSms(phone: string, message: string): Promise<boolean> {
const config = await this.getSmsConfig();
if (!config.enabled) {
await this.recordLog({
receptor: phone,
type: 'GENERIC_TEXT',
messageText: message,
status: 'DISABLED',
errorMessage: 'ارسال پیامک غیرفعال است.',
});
return false;
}
this.logger.log(`[SMS Text Sent] To: ${phone}, Content: ${message}`);
await this.recordLog({
receptor: phone,
type: 'GENERIC_TEXT',
messageText: message,
status: 'SUCCESS',
});
return Promise.resolve(true);
}
}