Initial commit: teaching institution management API.
Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// /utils/senders/baleBotSender.js
|
||||
|
||||
const axios = require('axios');
|
||||
const config = require('../../config/config');
|
||||
const logger = require('../logger');
|
||||
|
||||
const sendBaleMessage = async ({ chatId, body }) => {
|
||||
try {
|
||||
const targetChatId = chatId || 'default_channel';
|
||||
|
||||
if (config.BALE_BOT_TOKEN === 'mock_bale_bot_token') {
|
||||
logger.info(`[BaleBotSender MOCK] ChatID: ${targetChatId} | Message: "${body}"`);
|
||||
return { success: true, messageId: `bale_mock_${Date.now()}` };
|
||||
}
|
||||
|
||||
const url = `https://tapi.bale.ai/bot${config.BALE_BOT_TOKEN}/sendMessage`;
|
||||
const response = await axios.post(url, {
|
||||
chat_id: targetChatId,
|
||||
text: body
|
||||
}, { timeout: 5000 });
|
||||
|
||||
logger.info(`[BaleBotSender] Sent message to ${targetChatId}`);
|
||||
return { success: true, messageId: response.data?.result?.message_id || `bale_${Date.now()}` };
|
||||
} catch (error) {
|
||||
logger.error(`[BaleBotSender ERROR] Failed to send Bale message: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sendBaleMessage
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// /utils/senders/emailSender.js
|
||||
|
||||
const nodemailer = require('nodemailer');
|
||||
const config = require('../../config/config');
|
||||
const logger = require('../logger');
|
||||
|
||||
let transporter = null;
|
||||
|
||||
const getTransporter = () => {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.SMTP_HOST,
|
||||
port: config.SMTP_PORT,
|
||||
secure: config.SMTP_PORT === 465,
|
||||
auth: config.SMTP_USER ? {
|
||||
user: config.SMTP_USER,
|
||||
pass: config.SMTP_PASS
|
||||
} : undefined
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
};
|
||||
|
||||
const sendEmail = async ({ to, subject, body, html }) => {
|
||||
try {
|
||||
if (!to) throw new Error('Recipient email is required');
|
||||
|
||||
const mailOptions = {
|
||||
from: config.EMAIL_FROM,
|
||||
to,
|
||||
subject,
|
||||
text: body,
|
||||
html: html || `<p>${body}</p>`
|
||||
};
|
||||
|
||||
if (config.NODE_ENV === 'test' || !config.SMTP_USER) {
|
||||
logger.info(`[EmailSender MOCK] To: ${to} | Subject: ${subject} | Body: ${body}`);
|
||||
return { success: true, messageId: `mock_${Date.now()}` };
|
||||
}
|
||||
|
||||
const info = await getTransporter().sendMail(mailOptions);
|
||||
logger.info(`[EmailSender] Message sent: ${info.messageId} to ${to}`);
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
logger.error(`[EmailSender ERROR] Failed to send email to ${to}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sendEmail
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
// /utils/senders/notificationRecorder.js
|
||||
'use strict';
|
||||
|
||||
const Notification = require('../../components/notifications/notificationModel');
|
||||
const logger = require('../logger');
|
||||
|
||||
/**
|
||||
* Persist a notification row, attempt delivery, then mark sent/failed.
|
||||
* Use this for every outbound sms / email / baleBot message.
|
||||
*/
|
||||
const recordAndSend = async ({
|
||||
userId = null,
|
||||
channel,
|
||||
subject = '',
|
||||
body,
|
||||
relatedEvent = null,
|
||||
sendFn
|
||||
}) => {
|
||||
const notification = await Notification.create({
|
||||
user: userId || undefined,
|
||||
channel,
|
||||
subject: subject || undefined,
|
||||
body: body || subject || 'Notification',
|
||||
status: 'pending',
|
||||
relatedEvent: relatedEvent || undefined
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await sendFn();
|
||||
const skipped = result && result.skipped === true;
|
||||
notification.status = skipped ? 'failed' : 'sent';
|
||||
if (skipped) {
|
||||
notification.lastError = result.reason || 'Delivery skipped';
|
||||
} else {
|
||||
notification.sentAt = new Date();
|
||||
}
|
||||
await notification.save();
|
||||
return { notification, result };
|
||||
} catch (err) {
|
||||
notification.status = 'failed';
|
||||
notification.lastError = err.message;
|
||||
notification.retryCount = (notification.retryCount || 0) + 1;
|
||||
await notification.save();
|
||||
logger.error(`[NotificationRecorder] ${channel} failed: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
recordAndSend
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
// /utils/senders/sms.base.js
|
||||
'use strict';
|
||||
|
||||
const axios = require('axios');
|
||||
const config = require('../../config/config');
|
||||
const logger = require('../logger');
|
||||
|
||||
const toBoolean = (value) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value == null) return false;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return ['true', '1', 'yes', 'on'].includes(normalized);
|
||||
};
|
||||
|
||||
const sendSingleSms = async (mobile, templateId, params = []) => {
|
||||
console.log('[SMS] About to send notification:', {
|
||||
mobile,
|
||||
templateId,
|
||||
params,
|
||||
SMS_ENABLED: config.SMS_ENABLED,
|
||||
});
|
||||
|
||||
if (!toBoolean(config.SMS_ENABLED)) {
|
||||
logger.info(`[SMS] Skipped (SMS_ENABLED=false) → ${mobile} template=${templateId}`);
|
||||
return { skipped: true };
|
||||
}
|
||||
|
||||
if (!templateId) {
|
||||
logger.warn(`[SMS] Missing templateId for ${mobile}`);
|
||||
return { skipped: true, reason: 'missing_template' };
|
||||
}
|
||||
|
||||
if (!mobile) {
|
||||
logger.warn('[SMS] Missing mobile number');
|
||||
return { skipped: true, reason: 'missing_mobile' };
|
||||
}
|
||||
|
||||
const request = {
|
||||
method: 'POST',
|
||||
url: 'https://api.sms.ir/v1/send/verify',
|
||||
headers: {
|
||||
'X-API-KEY': config.SMS_PANEL_TOKEN,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
data: {
|
||||
mobile,
|
||||
templateId: Number(templateId) || templateId,
|
||||
parameters: params,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await axios(request);
|
||||
logger.info(`[SMS] Sent to ${mobile} template=${templateId}`);
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
logger.error(`[SMS] Failed to ${mobile}: ${err.response?.data?.message || err.message}`);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { sendSingleSms, toBoolean };
|
||||
@@ -0,0 +1,64 @@
|
||||
// /utils/senders/smsMessages.js
|
||||
'use strict';
|
||||
|
||||
const config = require('../../config/config');
|
||||
const { sendSingleSms } = require('./sms.base');
|
||||
const { recordAndSend } = require('./notificationRecorder');
|
||||
const User = require('../../components/users/userModel');
|
||||
|
||||
const resolveUserIdByPhone = async (phoneNumber) => {
|
||||
if (!phoneNumber) return null;
|
||||
const user = await User.findOne({ phoneNumber: String(phoneNumber) }).select('_id').lean();
|
||||
return user?._id || null;
|
||||
};
|
||||
|
||||
const sendAccountCreatedSms = async (receiver, username, password) => {
|
||||
const userId = await resolveUserIdByPhone(receiver);
|
||||
return recordAndSend({
|
||||
userId,
|
||||
channel: 'sms',
|
||||
subject: 'ایجاد حساب کاربری',
|
||||
body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`,
|
||||
relatedEvent: 'user.created',
|
||||
sendFn: () => sendSingleSms(receiver, config.SMS_TEMPLATE_ACCOUNT_CREATED, [
|
||||
{ name: 'username', value: String(username) },
|
||||
{ name: 'password', value: String(password) }
|
||||
])
|
||||
});
|
||||
};
|
||||
|
||||
const sendClassRegisteredSms = async (receiver, className, userId = null) => {
|
||||
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
|
||||
return recordAndSend({
|
||||
userId: resolvedUserId,
|
||||
channel: 'sms',
|
||||
subject: 'ثبتنام در کلاس',
|
||||
body: `ثبتنام شما در کلاس «${className}» انجام شد.`,
|
||||
relatedEvent: 'user.enrolled',
|
||||
sendFn: () => sendSingleSms(receiver, config.SMS_TEMPLATE_CLASS_REGISTERED, [
|
||||
{ name: 'className', value: String(className) }
|
||||
])
|
||||
});
|
||||
};
|
||||
|
||||
const sendClassReminderSms = async (receiver, className, time, place = '', userId = null) => {
|
||||
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
|
||||
return recordAndSend({
|
||||
userId: resolvedUserId,
|
||||
channel: 'sms',
|
||||
subject: 'یادآوری کلاس',
|
||||
body: `یادآوری کلاس «${className}» ساعت ${time} — مکان: ${place || '-'}`,
|
||||
relatedEvent: 'session.reminder',
|
||||
sendFn: () => sendSingleSms(receiver, config.SMS_TEMPLATE_CLASS_REMINDER, [
|
||||
{ name: 'className', value: String(className) },
|
||||
{ name: 'time', value: String(time) },
|
||||
{ name: 'place', value: String(place || '-') }
|
||||
])
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sendAccountCreatedSms,
|
||||
sendClassRegisteredSms,
|
||||
sendClassReminderSms
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// /utils/senders/smsSender.js
|
||||
'use strict';
|
||||
|
||||
const logger = require('../logger');
|
||||
const { sendSingleSms } = require('./sms.base');
|
||||
const {
|
||||
sendAccountCreatedSms,
|
||||
sendClassRegisteredSms,
|
||||
sendClassReminderSms,
|
||||
} = require('./smsMessages');
|
||||
|
||||
/**
|
||||
* Generic notification-path SMS. sms.ir verify API is template-based,
|
||||
* so free-text body sends are logged only unless a templateId is provided.
|
||||
*/
|
||||
const sendSMS = async ({ phoneNumber, body, templateId, parameters }) => {
|
||||
try {
|
||||
if (!phoneNumber) throw new Error('Phone number is required for SMS');
|
||||
|
||||
if (templateId) {
|
||||
return await sendSingleSms(phoneNumber, templateId, parameters || []);
|
||||
}
|
||||
|
||||
logger.info(`[SMSSender] Free-text SMS not sent via verify API to ${phoneNumber}: "${body}"`);
|
||||
return { success: true, skipped: true, reason: 'free_text_unsupported' };
|
||||
} catch (error) {
|
||||
logger.error(`[SMSSender ERROR] Failed to send SMS to ${phoneNumber}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sendSMS,
|
||||
sendAccountCreatedSms,
|
||||
sendClassRegisteredSms,
|
||||
sendClassReminderSms,
|
||||
};
|
||||
Reference in New Issue
Block a user