1362 lines
42 KiB
TypeScript
1362 lines
42 KiB
TypeScript
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(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/g, '&')
|
||
.trim();
|
||
const body = (bodyMatch ? bodyMatch[1] : '')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/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);
|
||
}
|
||
}
|