From 3160f0787334bfc4c24b44f7fbdce5ddf9c0a061 Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Sat, 15 Aug 2026 18:37:44 +0330 Subject: [PATCH] feat: send account and invoice SMS from stored template IDs Move sms.ir template IDs out of env into settings so SuperAdmin can manage them, and record every outbound SMS status in notifications. --- .env.example | 8 +- app.js | 2 + components/payments/paymentService.js | 11 +++ components/settings/settingController.js | 15 ++++ components/settings/settingModel.js | 28 +++++++ components/settings/settingRoutes.js | 16 ++++ components/settings/settingService.js | 97 ++++++++++++++++++++++++ components/settings/smsTemplates.js | 50 ++++++++++++ components/users/userService.js | 2 +- config/config.js | 4 - utils/senders/sms.base.js | 10 ++- utils/senders/smsMessages.js | 38 +++++++--- utils/senders/smsSender.js | 2 + 13 files changed, 261 insertions(+), 22 deletions(-) create mode 100644 components/settings/settingController.js create mode 100644 components/settings/settingModel.js create mode 100644 components/settings/settingRoutes.js create mode 100644 components/settings/settingService.js create mode 100644 components/settings/smsTemplates.js diff --git a/.env.example b/.env.example index b13016d..b87e58e 100644 --- a/.env.example +++ b/.env.example @@ -71,13 +71,7 @@ EMAIL_FROM=no-reply@institution.com SMS_ENABLED=false SMS_PANEL_TOKEN=your_sms_ir_api_key SMS_SENDER_NUMBER=10001000 -# Template IDs from sms.ir panel (verify templates) -# Account created vars: username, password -SMS_TEMPLATE_ACCOUNT_CREATED= -# Class registered vars: className -SMS_TEMPLATE_CLASS_REGISTERED= -# Class reminder (30 min before) vars: className, time, place -SMS_TEMPLATE_CLASS_REMINDER= +# SMS verify template IDs are stored in the database (SuperAdmin → Settings). # Bale Messenger Bot Token BALE_BOT_TOKEN=mock_bale_bot_token diff --git a/app.js b/app.js index b6a98e2..00905cc 100644 --- a/app.js +++ b/app.js @@ -81,6 +81,7 @@ app.get('/api/health', (req, res) => { const seedDatabase = require('./seed'); const seedRoutes = require('./components/seed/seedRoutes'); const dataImportRoutes = require('./components/dataImport/dataImportRoutes'); +const settingRoutes = require('./components/settings/settingRoutes'); app.use('/api/auth', authRoutes); app.use('/api/users', userRoutes); @@ -99,6 +100,7 @@ app.use('/api/activity-logs', activityLogRoutes); app.use('/api/contact-inquiries', contactInquiryRoutes); app.use('/api/seed', seedRoutes); app.use('/api/data-import', dataImportRoutes); +app.use('/api/settings', settingRoutes); // ── Error Handlers ──────────────────────────────────────────────────────────── app.use(notFoundHandler); diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index 4af7b5d..1b329c0 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -7,6 +7,8 @@ const eventEmitter = require('../../events/eventEmitter'); const EVENT_NAMES = require('../../constants/eventNames'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const User = require('../users/userModel'); +const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages'); +const logger = require('../../utils/logger'); const getAllPayments = async (query) => { const page = parseInt(query.page) || 1; @@ -71,6 +73,15 @@ const createPayment = async (body, actorId = null) => { }); } + try { + const user = await User.findById(payment.user).select('phoneNumber').lean(); + if (user?.phoneNumber) { + await sendInvoiceCreatedSms(user.phoneNumber, payment.amount, user._id); + } + } catch (err) { + logger.error(`[createPayment] Invoice SMS failed for payment ${payment._id}: ${err.message}`); + } + return getPaymentById(payment._id); }; diff --git a/components/settings/settingController.js b/components/settings/settingController.js new file mode 100644 index 0000000..44aa2ea --- /dev/null +++ b/components/settings/settingController.js @@ -0,0 +1,15 @@ +'use strict'; + +const catchAsync = require('../../utils/catchAsync'); +const settingService = require('./settingService'); +const { successResponse } = require('../../utils/apiResponse'); + +exports.getSettings = catchAsync(async (req, res) => { + const settings = await settingService.getSettings(); + return successResponse(res, 200, 'Settings retrieved', settings); +}); + +exports.saveSettings = catchAsync(async (req, res) => { + const settings = await settingService.saveSettings(req.body); + return successResponse(res, 200, 'Settings saved', settings); +}); diff --git a/components/settings/settingModel.js b/components/settings/settingModel.js new file mode 100644 index 0000000..dd0aab1 --- /dev/null +++ b/components/settings/settingModel.js @@ -0,0 +1,28 @@ +'use strict'; + +const mongoose = require('mongoose'); + +const SETTINGS_KEY = 'app'; + +const settingSchema = new mongoose.Schema({ + key: { + type: String, + required: true, + unique: true, + default: SETTINGS_KEY, + index: true + }, + smsTemplates: { + type: mongoose.Schema.Types.Mixed, + default: {} + } +}, { + timestamps: true +}); + +const Setting = mongoose.model('Setting', settingSchema); + +module.exports = { + Setting, + SETTINGS_KEY +}; diff --git a/components/settings/settingRoutes.js b/components/settings/settingRoutes.js new file mode 100644 index 0000000..e26555f --- /dev/null +++ b/components/settings/settingRoutes.js @@ -0,0 +1,16 @@ +'use strict'; + +const express = require('express'); +const settingController = require('./settingController'); +const authMiddleware = require('../../middlewares/authMiddleware'); +const requireSuperAdmin = require('../../middlewares/requireSuperAdmin'); + +const router = express.Router(); + +router.use(authMiddleware); +router.use(requireSuperAdmin); + +router.get('/', settingController.getSettings); +router.put('/', settingController.saveSettings); + +module.exports = router; diff --git a/components/settings/settingService.js b/components/settings/settingService.js new file mode 100644 index 0000000..2df4fbb --- /dev/null +++ b/components/settings/settingService.js @@ -0,0 +1,97 @@ +'use strict'; + +const AppError = require('../../utils/AppError'); +const { Setting, SETTINGS_KEY } = require('./settingModel'); +const { + SMS_TEMPLATE_DEFS, + envFallbackFor, + sanitizeTemplateId +} = require('./smsTemplates'); + +const emptyTemplateMap = () => { + const map = {}; + SMS_TEMPLATE_DEFS.forEach((def) => { + map[def.key] = ''; + }); + return map; +}; + +const readStoredMap = (doc) => { + const stored = doc?.smsTemplates || {}; + if (stored instanceof Map) { + return Object.fromEntries(stored.entries()); + } + return { ...stored }; +}; + +const resolveTemplateId = (storedMap, def) => { + const fromDb = String(storedMap[def.key] || '').trim(); + if (fromDb) return fromDb; + return envFallbackFor(def); +}; + +const toPublicTemplates = (storedMap) => { + return SMS_TEMPLATE_DEFS.map((def) => ({ + key: def.key, + label: def.label, + variables: def.variables, + templateId: resolveTemplateId(storedMap, def) + })); +}; + +const getSettings = async () => { + const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); + const storedMap = readStoredMap(doc); + return { + smsTemplates: toPublicTemplates(storedMap) + }; +}; + +const getSmsTemplateId = async (key) => { + const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key); + if (!def) return ''; + const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); + return resolveTemplateId(readStoredMap(doc), def); +}; + +const saveSettings = async (body = {}) => { + const incoming = body.smsTemplates || {}; + const incomingMap = Array.isArray(incoming) + ? Object.fromEntries(incoming.map((item) => [item.key, item.templateId])) + : incoming; + + const existing = await Setting.findOne({ key: SETTINGS_KEY }); + const storedMap = existing ? readStoredMap(existing) : emptyTemplateMap(); + const nextMap = emptyTemplateMap(); + + for (const def of SMS_TEMPLATE_DEFS) { + const hasIncoming = Object.prototype.hasOwnProperty.call(incomingMap, def.key); + if (hasIncoming) { + const sanitized = sanitizeTemplateId(incomingMap[def.key]); + if (sanitized === null) { + throw new AppError('VALIDATION_FAILED', { field: def.key }, `Invalid SMS template ID for ${def.key}`); + } + nextMap[def.key] = sanitized; + } else if (storedMap[def.key]) { + nextMap[def.key] = String(storedMap[def.key]).trim(); + } else { + nextMap[def.key] = envFallbackFor(def); + } + } + + const doc = await Setting.findOneAndUpdate( + { key: SETTINGS_KEY }, + { $set: { smsTemplates: nextMap } }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + return { + smsTemplates: toPublicTemplates(readStoredMap(doc)) + }; +}; + +module.exports = { + getSettings, + saveSettings, + getSmsTemplateId +}; diff --git a/components/settings/smsTemplates.js b/components/settings/smsTemplates.js new file mode 100644 index 0000000..decb43d --- /dev/null +++ b/components/settings/smsTemplates.js @@ -0,0 +1,50 @@ +'use strict'; + +const SMS_TEMPLATE_DEFS = [ + { + key: 'accountCreated', + label: 'ایجاد حساب کاربری', + variables: ['user', 'password'], + envKey: 'SMS_TEMPLATE_ACCOUNT_CREATED' + }, + { + key: 'classRegistered', + label: 'ثبت‌نام در کلاس', + variables: ['className'], + envKey: 'SMS_TEMPLATE_CLASS_REGISTERED' + }, + { + key: 'classReminder', + label: 'یادآوری کلاس', + variables: ['className', 'time', 'place'], + envKey: 'SMS_TEMPLATE_CLASS_REMINDER' + }, + { + key: 'invoiceCreated', + label: 'ایجاد صورتحساب', + variables: ['amount'], + envKey: 'SMS_TEMPLATE_INVOICE_CREATED' + } +]; + +const SMS_TEMPLATE_KEYS = SMS_TEMPLATE_DEFS.map((def) => def.key); + +const envFallbackFor = (def) => { + if (!def?.envKey) return ''; + return String(process.env[def.envKey] || '').trim(); +}; + +const sanitizeTemplateId = (value) => { + if (value == null) return ''; + const digits = String(value).trim(); + if (!digits) return ''; + if (!/^\d{1,20}$/.test(digits)) return null; + return digits; +}; + +module.exports = { + SMS_TEMPLATE_DEFS, + SMS_TEMPLATE_KEYS, + envFallbackFor, + sanitizeTemplateId +}; diff --git a/components/users/userService.js b/components/users/userService.js index 65963ba..a5b5413 100644 --- a/components/users/userService.js +++ b/components/users/userService.js @@ -175,7 +175,7 @@ const createUserAdmin = async (body) => { }); try { - await sendAccountCreatedSms(profile.phoneNumber, username, plainPassword); + await sendAccountCreatedSms(profile.phoneNumber, username, plainPassword, user._id); } catch (err) { logger.error(`[createUserAdmin] Account SMS failed for ${profile.phoneNumber}: ${err.message}`); } diff --git a/config/config.js b/config/config.js index 6f4823a..5bc69f0 100644 --- a/config/config.js +++ b/config/config.js @@ -106,10 +106,6 @@ const config = { SMS_PANEL_TOKEN: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || '', SMS_API_KEY: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || 'mock_sms_key', SMS_SENDER_NUMBER: process.env.SMS_SENDER_NUMBER || '10001000', - // Template IDs configured in the sms.ir panel - SMS_TEMPLATE_ACCOUNT_CREATED: process.env.SMS_TEMPLATE_ACCOUNT_CREATED || '', - SMS_TEMPLATE_CLASS_REGISTERED: process.env.SMS_TEMPLATE_CLASS_REGISTERED || '', - SMS_TEMPLATE_CLASS_REMINDER: process.env.SMS_TEMPLATE_CLASS_REMINDER || '', // Bale Messenger Bot Settings BALE_BOT_TOKEN: process.env.BALE_BOT_TOKEN || 'mock_bale_bot_token', diff --git a/utils/senders/sms.base.js b/utils/senders/sms.base.js index 0f5d0c9..e2f5f20 100644 --- a/utils/senders/sms.base.js +++ b/utils/senders/sms.base.js @@ -12,11 +12,19 @@ const toBoolean = (value) => { return ['true', '1', 'yes', 'on'].includes(normalized); }; +const redactSmsParams = (params = []) => ( + params.map((param) => ( + String(param?.name || '').toLowerCase() === 'password' + ? { ...param, value: '[redacted]' } + : param + )) +); + const sendSingleSms = async (mobile, templateId, params = []) => { console.log('[SMS] About to send notification:', { mobile, templateId, - params, + params: redactSmsParams(params), SMS_ENABLED: config.SMS_ENABLED, }); diff --git a/utils/senders/smsMessages.js b/utils/senders/smsMessages.js index 4d4fe89..d652db8 100644 --- a/utils/senders/smsMessages.js +++ b/utils/senders/smsMessages.js @@ -1,9 +1,9 @@ // /utils/senders/smsMessages.js 'use strict'; -const config = require('../../config/config'); const { sendSingleSms } = require('./sms.base'); const { recordAndSend } = require('./notificationRecorder'); +const { getSmsTemplateId } = require('../../components/settings/settingService'); const User = require('../../components/users/userModel'); const resolveUserIdByPhone = async (phoneNumber) => { @@ -12,16 +12,17 @@ const resolveUserIdByPhone = async (phoneNumber) => { return user?._id || null; }; -const sendAccountCreatedSms = async (receiver, username, password) => { - const userId = await resolveUserIdByPhone(receiver); +const sendAccountCreatedSms = async (receiver, username, password, userId = null) => { + const resolvedUserId = userId || await resolveUserIdByPhone(receiver); + const templateId = await getSmsTemplateId('accountCreated'); return recordAndSend({ - userId, + userId: resolvedUserId, channel: 'sms', subject: 'ایجاد حساب کاربری', body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`, relatedEvent: 'user.created', - sendFn: () => sendSingleSms(receiver, config.SMS_TEMPLATE_ACCOUNT_CREATED, [ - { name: 'username', value: String(username) }, + sendFn: () => sendSingleSms(receiver, templateId, [ + { name: 'user', value: String(username) }, { name: 'password', value: String(password) } ]) }); @@ -29,13 +30,14 @@ const sendAccountCreatedSms = async (receiver, username, password) => { const sendClassRegisteredSms = async (receiver, className, userId = null) => { const resolvedUserId = userId || await resolveUserIdByPhone(receiver); + const templateId = await getSmsTemplateId('classRegistered'); return recordAndSend({ userId: resolvedUserId, channel: 'sms', subject: 'ثبت‌نام در کلاس', body: `ثبت‌نام شما در کلاس «${className}» انجام شد.`, relatedEvent: 'user.enrolled', - sendFn: () => sendSingleSms(receiver, config.SMS_TEMPLATE_CLASS_REGISTERED, [ + sendFn: () => sendSingleSms(receiver, templateId, [ { name: 'className', value: String(className) } ]) }); @@ -43,13 +45,14 @@ const sendClassRegisteredSms = async (receiver, className, userId = null) => { const sendClassReminderSms = async (receiver, className, time, place = '', userId = null) => { const resolvedUserId = userId || await resolveUserIdByPhone(receiver); + const templateId = await getSmsTemplateId('classReminder'); return recordAndSend({ userId: resolvedUserId, channel: 'sms', subject: 'یادآوری کلاس', body: `یادآوری کلاس «${className}» ساعت ${time} — مکان: ${place || '-'}`, relatedEvent: 'session.reminder', - sendFn: () => sendSingleSms(receiver, config.SMS_TEMPLATE_CLASS_REMINDER, [ + sendFn: () => sendSingleSms(receiver, templateId, [ { name: 'className', value: String(className) }, { name: 'time', value: String(time) }, { name: 'place', value: String(place || '-') } @@ -57,8 +60,25 @@ const sendClassReminderSms = async (receiver, className, time, place = '', userI }); }; +const sendInvoiceCreatedSms = async (receiver, amount, userId = null) => { + const resolvedUserId = userId || await resolveUserIdByPhone(receiver); + const templateId = await getSmsTemplateId('invoiceCreated'); + const amountLabel = String(amount); + return recordAndSend({ + userId: resolvedUserId, + channel: 'sms', + subject: 'ایجاد صورتحساب', + body: `صورتحساب جدید به مبلغ ${amountLabel} تومان صادر شد.`, + relatedEvent: 'payment.created', + sendFn: () => sendSingleSms(receiver, templateId, [ + { name: 'amount', value: amountLabel } + ]) + }); +}; + module.exports = { sendAccountCreatedSms, sendClassRegisteredSms, - sendClassReminderSms + sendClassReminderSms, + sendInvoiceCreatedSms }; diff --git a/utils/senders/smsSender.js b/utils/senders/smsSender.js index 560838f..e1f43dc 100644 --- a/utils/senders/smsSender.js +++ b/utils/senders/smsSender.js @@ -7,6 +7,7 @@ const { sendAccountCreatedSms, sendClassRegisteredSms, sendClassReminderSms, + sendInvoiceCreatedSms, } = require('./smsMessages'); /** @@ -34,4 +35,5 @@ module.exports = { sendAccountCreatedSms, sendClassRegisteredSms, sendClassReminderSms, + sendInvoiceCreatedSms, };