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 { try { const setting = await this.prisma.setting.findFirst({ where: { category: 'sms' }, }); const dbConfig = (setting?.value as Record) || {}; 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, ): Promise { 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 = {}; 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, ): Promise { 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 { 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 and child tags , , <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); } }