Initial commit: teaching institution management API.

Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs.
This commit is contained in:
2026-08-09 04:18:08 +02:00
commit f04c797be6
107 changed files with 9190 additions and 0 deletions
+32
View File
@@ -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
};
+52
View File
@@ -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
};
+51
View File
@@ -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
};
+62
View File
@@ -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 };
+64
View File
@@ -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
};
+37
View File
@@ -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,
};