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
+22
View File
@@ -0,0 +1,22 @@
// /utils/AppError.js
const errors = require('./errors.json');
class AppError extends Error {
constructor(errorCode, details = null, overrideMessage = null) {
const errorDef = errors[errorCode] || errors.INTERNAL_SERVER_ERROR;
const message = overrideMessage || errorDef.en;
super(message);
this.name = 'AppError';
this.errorCode = errorCode in errors ? errorCode : 'INTERNAL_SERVER_ERROR';
this.statusCode = errorDef.statusCode || 500;
this.details = details;
this.overrideMessage = overrideMessage;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
+42
View File
@@ -0,0 +1,42 @@
// /utils/apiResponse.js
const successResponse = (res, statusCode = 200, message = 'Operation successful', data = null) => {
const response = {
success: true,
message
};
if (data !== null) {
response.data = data;
}
return res.status(statusCode).json(response);
};
const listResponse = (res, statusCode = 200, data = [], meta = {}) => {
return res.status(statusCode).json({
success: true,
data,
meta: {
totalCount: meta.totalCount || 0,
totalPages: meta.totalPages || 0,
currentPage: meta.currentPage || 1,
limit: meta.limit || 20
}
});
};
const errorResponse = (res, statusCode = 500, code = 'INTERNAL_SERVER_ERROR', message = 'An error occurred', details = {}) => {
return res.status(statusCode).json({
success: false,
error: {
code,
message,
details: details || {}
}
});
};
module.exports = {
successResponse,
listResponse,
errorResponse
};
+9
View File
@@ -0,0 +1,9 @@
// /utils/catchAsync.js
const catchAsync = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
module.exports = catchAsync;
+27
View File
@@ -0,0 +1,27 @@
// /utils/credentials.js
'use strict';
const crypto = require('crypto');
const LETTERS = 'abcdefghijkmnpqrstuvwxyz';
const DIGITS = '23456789';
const randomFrom = (alphabet, length) => {
let out = '';
const bytes = crypto.randomBytes(length);
for (let i = 0; i < length; i += 1) {
out += alphabet[bytes[i] % alphabet.length];
}
return out;
};
/** Simple password: exactly 2 English letters + 4 digits (e.g. kx4821) */
const generateSimplePassword = () => `${randomFrom(LETTERS, 2)}${randomFrom(DIGITS, 4)}`;
/** Random username: u + 6 digits (e.g. u482910) */
const generateUsername = () => `u${randomFrom(DIGITS, 6)}`;
module.exports = {
generateSimplePassword,
generateUsername,
};
+28
View File
@@ -0,0 +1,28 @@
// /utils/errorLocalization.js
const errors = require('./errors.json');
const config = require('../config/config');
const resolveLanguage = (req) => {
if (req && req.query && req.query.lang) {
const lang = req.query.lang.toLowerCase();
if (['fa', 'en'].includes(lang)) return lang;
}
if (req && req.headers && req.headers['accept-language']) {
const headerLang = req.headers['accept-language'].toLowerCase();
if (headerLang.includes('fa')) return 'fa';
if (headerLang.includes('en')) return 'en';
}
return config.DEFAULT_LANG || 'en';
};
const getLocalizedErrorMessage = (errorCode, req = null) => {
const lang = resolveLanguage(req);
const errorDef = errors[errorCode] || errors.INTERNAL_SERVER_ERROR;
return errorDef[lang] || errorDef.en || 'An unexpected error occurred.';
};
module.exports = {
resolveLanguage,
getLocalizedErrorMessage
};
+142
View File
@@ -0,0 +1,142 @@
{
"USER_NOT_FOUND": {
"statusCode": 404,
"en": "User not found.",
"fa": "کاربر یافت نشد."
},
"USER_ALREADY_EXISTS": {
"statusCode": 409,
"en": "User with this national ID, phone number, or email already exists.",
"fa": "کاربری با این کدملی، شماره تلفن یا ایمیل از قبل وجود دارد."
},
"PROFESSOR_NOT_FOUND": {
"statusCode": 404,
"en": "Professor not found.",
"fa": "استاد یافت نشد."
},
"PROFESSOR_ALREADY_EXISTS": {
"statusCode": 409,
"en": "Professor with this national ID, phone number, or email already exists.",
"fa": "استادی با این کدملی، شماره تلفن یا ایمیل از قبل وجود دارد."
},
"COURSE_NOT_FOUND": {
"statusCode": 404,
"en": "Course not found.",
"fa": "دوره یافت نشد."
},
"COURSE_CAPACITY_FULL": {
"statusCode": 400,
"en": "Course capacity is full.",
"fa": "ظرفیت دوره تکمیل است."
},
"ALREADY_ENROLLED": {
"statusCode": 400,
"en": "User is already enrolled in this course.",
"fa": "کاربر قبلاً در این دوره ثبت‌نام کرده است."
},
"SESSION_NOT_FOUND": {
"statusCode": 404,
"en": "Session not found.",
"fa": "جلسه یافت نشد."
},
"ATTENDANCE_NOT_FOUND": {
"statusCode": 404,
"en": "Attendance record not found.",
"fa": "سابقه حضور و غیاب یافت نشد."
},
"ATTENDANCE_ALREADY_EXISTS": {
"statusCode": 409,
"en": "Attendance record already exists for this student in this session.",
"fa": "سابقه حضور و غیاب برای این دانشجو در این جلسه قبلاً ثبت شده است."
},
"PAYMENT_NOT_FOUND": {
"statusCode": 404,
"en": "Payment record not found.",
"fa": "سابقه پرداخت یافت نشد."
},
"ROLE_NOT_FOUND": {
"statusCode": 404,
"en": "Role not found.",
"fa": "نقش یافت نشد."
},
"ROLE_ALREADY_EXISTS": {
"statusCode": 409,
"en": "Role with this name already exists.",
"fa": "نقشی با این نام از قبل وجود دارد."
},
"SYSTEM_ROLE_PROTECTED": {
"statusCode": 403,
"en": "System roles cannot be modified or deleted.",
"fa": "نقش‌های سیستمی قابل تغییر یا حذف نیستند."
},
"NOTIFICATION_NOT_FOUND": {
"statusCode": 404,
"en": "Notification record not found.",
"fa": "اعلان یافت نشد."
},
"CERTIFICATE_NOT_FOUND": {
"statusCode": 404,
"en": "Certificate not found.",
"fa": "مدرک یافت نشد."
},
"CLASS_NOT_FOUND": {
"statusCode": 404,
"en": "Class not found.",
"fa": "کلاس یافت نشد."
},
"DEFAULT_ROLE_NOT_FOUND": {
"statusCode": 500,
"en": "Default user role was not found. Please seed the database.",
"fa": "نقش پیش‌فرض کاربر یافت نشد. لطفاً دیتابیس را seed کنید."
},
"VALIDATION_FAILED": {
"statusCode": 400,
"en": "Validation failed.",
"fa": "اعتبارسنجی ناموفق بود."
},
"UNAUTHORIZED": {
"statusCode": 401,
"en": "Authentication required. Invalid or missing token.",
"fa": "احراز هویت لازم است. توکن نامعتبر یا یافت نشد."
},
"FORBIDDEN": {
"statusCode": 403,
"en": "Access denied. Insufficient permissions.",
"fa": "دسترسی رد شد. مجوز کافی ندارید."
},
"INVALID_CREDENTIALS": {
"statusCode": 401,
"en": "Invalid username or password.",
"fa": "نام کاربری یا رمز عبور اشتباه است."
},
"TOKEN_EXPIRED": {
"statusCode": 401,
"en": "Token has expired. Please login again.",
"fa": "توکن منقضی شده است. لطفا مجددا وارد شوید."
},
"INVALID_REFRESH_TOKEN": {
"statusCode": 401,
"en": "Invalid or revoked refresh token.",
"fa": "توکن بازنشانی نامعتبر یا باطل شده است."
},
"DUPLICATE_KEY": {
"statusCode": 409,
"en": "Duplicate key error. A unique field already exists.",
"fa": "خطای کلید تکراری. مقداری یکتا از قبل وجود دارد."
},
"NOT_FOUND": {
"statusCode": 404,
"en": "Requested route or resource not found.",
"fa": "مسیر یا منبع درخواستی یافت نشد."
},
"INTERNAL_SERVER_ERROR": {
"statusCode": 500,
"en": "An internal server error occurred.",
"fa": "خطای داخلی سرور رخ داده است."
},
"FILE_REQUIRED": {
"statusCode": 400,
"en": "File is required.",
"fa": "ارسال فایل الزامی است."
}
}
+26
View File
@@ -0,0 +1,26 @@
// /utils/logger.js
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
),
defaultMeta: { service: 'institution-api' },
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, service, stack }) => {
return `[${timestamp}] [${level}]: ${stack || message}`;
})
)
})
]
});
module.exports = logger;
+69
View File
@@ -0,0 +1,69 @@
// /utils/pagination.js
const parsePaginationAndSort = (query, defaultSortBy = 'createdAt', defaultSortOrder = 'desc', maxLimit = 100) => {
const page = Math.max(1, parseInt(query.page, 10) || 1);
let limit = parseInt(query.limit, 10) || 20;
if (limit <= 0) limit = 20;
if (limit > maxLimit) limit = maxLimit;
const skip = (page - 1) * limit;
const sortBy = query.sortBy || defaultSortBy;
const sortOrder = (query.sortOrder || defaultSortOrder).toLowerCase() === 'asc' ? 1 : -1;
const sort = {};
sort[sortBy] = sortOrder;
return {
page,
limit,
skip,
sort
};
};
const buildFilterQuery = (query, searchFields = [], excludedKeys = ['page', 'limit', 'sortBy', 'sortOrder', 'q', 'lang']) => {
const filter = {};
// Build field-based exact or boolean filters
Object.keys(query).forEach((key) => {
if (!excludedKeys.includes(key) && query[key] !== undefined && query[key] !== '') {
const val = query[key];
if (val === 'true') {
filter[key] = true;
} else if (val === 'false') {
filter[key] = false;
} else if (!isNaN(val) && String(Number(val)) === val) {
filter[key] = Number(val);
} else {
filter[key] = val;
}
}
});
// Build regex search for ?q= across defined text fields
if (query.q && searchFields.length > 0) {
const searchRegex = new RegExp(query.q, 'i');
filter.$or = searchFields.map((field) => ({
[field]: searchRegex
}));
}
return filter;
};
const calculateMeta = (totalCount, page, limit) => {
const totalPages = Math.ceil(totalCount / limit) || 0;
return {
totalCount,
totalPages,
currentPage: page,
limit
};
};
module.exports = {
parsePaginationAndSort,
buildFilterQuery,
calculateMeta
};
+160
View File
@@ -0,0 +1,160 @@
// /utils/s3Client.js
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
CopyObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command
} = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const config = require('../config/config');
const logger = require('./logger');
let s3ClientInstance = null;
const getS3Client = () => {
if (!s3ClientInstance) {
s3ClientInstance = new S3Client({
endpoint: config.S3_ENDPOINT,
region: config.S3_REGION,
credentials: {
accessKeyId: config.S3_ACCESS_KEY,
secretAccessKey: config.S3_SECRET_KEY
},
forcePathStyle: config.S3_FORCE_PATH_STYLE
});
}
return s3ClientInstance;
};
const uploadToTempBucket = async (fileBuffer, filename, contentType = 'application/octet-stream') => {
try {
const client = getS3Client();
const command = new PutObjectCommand({
Bucket: config.S3_TEMP_BUCKET,
Key: filename,
Body: fileBuffer,
ContentType: contentType
});
await client.send(command);
logger.info(`[S3 Storage] Uploaded temp file: ${filename} to bucket ${config.S3_TEMP_BUCKET}`);
return { tempFileName: filename, bucket: config.S3_TEMP_BUCKET };
} catch (error) {
logger.error(`[S3 Storage ERROR] Temp upload failed for ${filename}: ${error.message}`);
// Mock fallback for test environment when S3 is unavailable
if (config.NODE_ENV === 'test' || error.code === 'ECONNREFUSED') {
logger.warn(`[S3 Storage MOCK] Simulated temp upload for ${filename}`);
return { tempFileName: filename, bucket: config.S3_TEMP_BUCKET };
}
throw error;
}
};
const commitTempFile = async (tempFilename, targetFilename = null) => {
const destinationKey = targetFilename || tempFilename;
try {
const client = getS3Client();
// 1. Copy object from Temp Bucket to Main Storage Bucket
const copyCommand = new CopyObjectCommand({
CopySource: `${config.S3_TEMP_BUCKET}/${tempFilename}`,
Bucket: config.S3_STORAGE_BUCKET,
Key: destinationKey
});
await client.send(copyCommand);
// 2. Delete object from Temp Bucket
const deleteCommand = new DeleteObjectCommand({
Bucket: config.S3_TEMP_BUCKET,
Key: tempFilename
});
await client.send(deleteCommand);
logger.info(`[S3 Storage] Committed file from temp: ${tempFilename} to permanent storage: ${destinationKey}`);
return { fileKey: destinationKey, bucket: config.S3_STORAGE_BUCKET };
} catch (error) {
logger.error(`[S3 Storage ERROR] Failed to commit temp file ${tempFilename}: ${error.message}`);
if (config.NODE_ENV === 'test' || error.code === 'ECONNREFUSED') {
logger.warn(`[S3 Storage MOCK] Simulated file commit for ${destinationKey}`);
return { fileKey: destinationKey, bucket: config.S3_STORAGE_BUCKET };
}
throw error;
}
};
const generatePresignedUrl = async (filename, bucketName = config.S3_STORAGE_BUCKET, expiresIn = config.SIGNED_URL_EXPIRES_IN) => {
try {
const client = getS3Client();
const command = new GetObjectCommand({
Bucket: bucketName,
Key: filename
});
const presignedUrl = await getSignedUrl(client, command, { expiresIn });
return presignedUrl;
} catch (error) {
logger.error(`[S3 Storage ERROR] Failed to generate presigned URL for ${filename}: ${error.message}`);
// Mock fallback URL for development without active S3 server
return `${config.S3_ENDPOINT}/${bucketName}/${filename}?token=mock_presigned_${Date.now()}`;
}
};
const deleteFromBucket = async (filename, bucketName = config.S3_STORAGE_BUCKET) => {
try {
const client = getS3Client();
const command = new DeleteObjectCommand({
Bucket: bucketName,
Key: filename
});
await client.send(command);
logger.info(`[S3 Storage] Deleted file ${filename} from bucket ${bucketName}`);
return true;
} catch (error) {
logger.error(`[S3 Storage ERROR] Failed to delete file ${filename}: ${error.message}`);
return false;
}
};
const cleanupTempBucket = async (olderThanMinutes = 10) => {
try {
const client = getS3Client();
const listCommand = new ListObjectsV2Command({
Bucket: config.S3_TEMP_BUCKET
});
const listResult = await client.send(listCommand);
if (!listResult.Contents || listResult.Contents.length === 0) {
logger.info('[S3 Temp Cleanup] Temp bucket is empty. Nothing to clean.');
return 0;
}
const cutoffTime = new Date(Date.now() - olderThanMinutes * 60 * 1000);
let deletedCount = 0;
for (const object of listResult.Contents) {
if (object.LastModified && new Date(object.LastModified) < cutoffTime) {
await deleteFromBucket(object.Key, config.S3_TEMP_BUCKET);
deletedCount++;
logger.info(`[S3 Temp Cleanup] Deleted expired temp file: ${object.Key} (Last modified: ${object.LastModified})`);
}
}
logger.info(`[S3 Temp Cleanup] Daily temp bucket cleanup complete. Removed ${deletedCount} files.`);
return deletedCount;
} catch (error) {
logger.error(`[S3 Temp Cleanup ERROR] Cleanup job failed: ${error.message}`);
return 0;
}
};
module.exports = {
getS3Client,
uploadToTempBucket,
commitTempFile,
generatePresignedUrl,
deleteFromBucket,
cleanupTempBucket
};
+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,
};