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,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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// /utils/catchAsync.js
|
||||
|
||||
const catchAsync = (fn) => {
|
||||
return (req, res, next) => {
|
||||
fn(req, res, next).catch(next);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = catchAsync;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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": "ارسال فایل الزامی است."
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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