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, };