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
@@ -0,0 +1,25 @@
// /components/contactInquiries/contactInquiryController.js
const catchAsync = require('../../utils/catchAsync');
const contactInquiryService = require('./contactInquiryService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res) => {
const inquiry = await contactInquiryService.createInquiry(req.body);
return successResponse(res, 201, 'Contact inquiry submitted successfully', inquiry);
});
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await contactInquiryService.getAllInquiries(req.query);
return listResponse(res, 200, data, meta);
});
exports.getOne = catchAsync(async (req, res) => {
const inquiry = await contactInquiryService.getInquiryById(req.params.id);
return successResponse(res, 200, 'Contact inquiry retrieved successfully', inquiry);
});
exports.update = catchAsync(async (req, res) => {
const inquiry = await contactInquiryService.updateInquiry(req.params.id, req.body);
return successResponse(res, 200, 'Contact inquiry updated successfully', inquiry);
});
@@ -0,0 +1,81 @@
// /components/contactInquiries/contactInquiryModel.js
const mongoose = require('mongoose');
const CONTACT_METHODS = [
'WhatsApp',
'Telegram',
'Soroush',
'Bale',
'Eitaa',
'SMS',
'Call'
];
const CONTACT_STATUSES = [
'new',
'seen',
'call_later',
'contacted',
'closed'
];
const contactInquirySchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true
},
surname: {
type: String,
required: true,
trim: true
},
nationalIdCode: {
type: String,
trim: true,
index: true
},
phoneNumber: {
type: String,
trim: true,
index: true
},
email: {
type: String,
trim: true,
lowercase: true
},
message: {
type: String,
trim: true,
maxlength: 2000
},
preferredContactMethods: [{
type: String,
enum: CONTACT_METHODS
}],
courses: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
}],
status: {
type: String,
enum: CONTACT_STATUSES,
default: 'new',
index: true
},
notes: {
type: String,
trim: true,
maxlength: 5000,
default: ''
}
}, {
timestamps: true
});
contactInquirySchema.statics.CONTACT_METHODS = CONTACT_METHODS;
contactInquirySchema.statics.CONTACT_STATUSES = CONTACT_STATUSES;
module.exports = mongoose.model('ContactInquiry', contactInquirySchema);
@@ -0,0 +1,35 @@
// /components/contactInquiries/contactInquiryRoutes.js
const express = require('express');
const contactInquiryController = require('./contactInquiryController');
const { validateCreateInquiry } = require('./contactInquiryValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
// Public — website contact form
router.post('/public/create', validateCreateInquiry, contactInquiryController.create);
// Admin
router.get(
'/admin/get-all',
authMiddleware,
perm.requires(PERMISSIONS.CONTACT_INQUIRIES_READ),
contactInquiryController.getAll
);
router.get(
'/admin/get-one/:id',
authMiddleware,
perm.requires(PERMISSIONS.CONTACT_INQUIRIES_READ),
contactInquiryController.getOne
);
router.put(
'/admin/update/:id',
authMiddleware,
perm.requires(PERMISSIONS.CONTACT_INQUIRIES_UPDATE),
contactInquiryController.update
);
module.exports = router;
@@ -0,0 +1,122 @@
// /components/contactInquiries/contactInquiryService.js
const ContactInquiry = require('./contactInquiryModel');
const Course = require('../courses/courseModel');
const AppError = require('../../utils/AppError');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const normalizePhone = (value) => {
if (!value) return undefined;
return String(value).replace(/[\s\-()]/g, '').trim();
};
const createInquiry = async (data) => {
const preferredContactMethods = Array.isArray(data.preferredContactMethods)
? [...new Set(data.preferredContactMethods)]
: [];
if (!preferredContactMethods.length) {
throw new AppError('VALIDATION_FAILED', {
preferredContactMethods: 'At least one preferred contact method is required'
}, 'حداقل یک روش تماس را انتخاب کنید.');
}
const phoneNumber = normalizePhone(data.phoneNumber || data.phone);
const email = data.email ? String(data.email).trim().toLowerCase() : undefined;
if (!phoneNumber && !email) {
throw new AppError('VALIDATION_FAILED', {
contact: 'Phone number or email is required'
}, 'شماره تلفن یا ایمیل الزامی است.');
}
const courseIds = Array.isArray(data.courses)
? [...new Set(data.courses.filter(Boolean).map(String))]
: [];
if (courseIds.length) {
const foundCount = await Course.countDocuments({
_id: { $in: courseIds },
showOnFrontend: { $ne: false }
});
if (foundCount !== courseIds.length) {
throw new AppError('COURSE_NOT_FOUND', null, 'یکی از دوره‌های انتخاب‌شده یافت نشد.');
}
}
const inquiry = await ContactInquiry.create({
name: data.name || data.firstName,
surname: data.surname || data.lastName,
nationalIdCode: data.nationalIdCode || data.nationalId || undefined,
phoneNumber,
email,
message: data.message || undefined,
preferredContactMethods,
courses: courseIds
});
return inquiry.populate('courses', 'title type price');
};
const getAllInquiries = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams, 'createdAt', 'desc');
const filter = buildFilterQuery(
queryParams,
['name', 'surname', 'nationalIdCode', 'phoneNumber', 'email', 'notes', 'message'],
['page', 'limit', 'sortBy', 'sortOrder', 'q', 'lang', 'course', 'courseId', 'courses']
);
const courseFilter = queryParams.course || queryParams.courseId || queryParams.courses;
if (courseFilter) {
const ids = String(courseFilter).split(',').map((id) => id.trim()).filter(Boolean);
if (ids.length === 1) filter.courses = ids[0];
else if (ids.length > 1) filter.courses = { $in: ids };
}
const [data, totalCount] = await Promise.all([
ContactInquiry.find(filter)
.populate('courses', 'title type price')
.sort(sort)
.skip(skip)
.limit(limit),
ContactInquiry.countDocuments(filter)
]);
return { data, meta: calculateMeta(totalCount, page, limit) };
};
const getInquiryById = async (id) => {
const inquiry = await ContactInquiry.findById(id).populate('courses', 'title type price');
if (!inquiry) {
throw new AppError('NOT_FOUND', null, 'درخواست تماس یافت نشد.');
}
return inquiry;
};
const updateInquiry = async (id, updateData) => {
const inquiry = await ContactInquiry.findById(id);
if (!inquiry) {
throw new AppError('NOT_FOUND', null, 'درخواست تماس یافت نشد.');
}
if (updateData.status !== undefined) {
if (!ContactInquiry.CONTACT_STATUSES.includes(updateData.status)) {
throw new AppError('VALIDATION_FAILED', { status: 'Invalid status' }, 'وضعیت نامعتبر است.');
}
inquiry.status = updateData.status;
}
if (updateData.notes !== undefined) {
inquiry.notes = String(updateData.notes).slice(0, 5000);
}
await inquiry.save();
return getInquiryById(id);
};
module.exports = {
createInquiry,
getAllInquiries,
getInquiryById,
updateInquiry
};
@@ -0,0 +1,91 @@
// /components/contactInquiries/contactInquiryValidator.js
const AppError = require('../../utils/AppError');
const ContactInquiry = require('./contactInquiryModel');
const IRAN_MOBILE = /^(0?9\d{9}|\+989\d{9}|00989\d{9})$/;
const EMAIL_RE = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/;
const isValidNationalId = (code) => {
if (!/^\d{10}$/.test(code)) return false;
if (/^(\d)\1{9}$/.test(code)) return false;
const check = Number(code[9]);
const sum = code
.split('')
.slice(0, 9)
.reduce((acc, digit, index) => acc + Number(digit) * (10 - index), 0);
const remainder = sum % 11;
return (remainder < 2 && check === remainder) || (remainder >= 2 && check === 11 - remainder);
};
const validateCreateInquiry = (req, res, next) => {
const body = req.body || {};
const details = {};
const name = (body.name || body.firstName || '').trim();
const surname = (body.surname || body.lastName || '').trim();
const nationalIdCode = (body.nationalIdCode || body.nationalId || '').trim();
const phoneNumber = String(body.phoneNumber || body.phone || '').replace(/[\s\-()]/g, '').trim();
const email = (body.email || '').trim();
const message = (body.message || '').trim();
const preferredContactMethods = Array.isArray(body.preferredContactMethods)
? body.preferredContactMethods
: [];
const courses = Array.isArray(body.courses) ? body.courses : [];
if (!name) details.name = 'نام الزامی است';
if (!surname) details.surname = 'نام خانوادگی الزامی است';
if (nationalIdCode && !isValidNationalId(nationalIdCode)) {
details.nationalIdCode = 'کد ملی معتبر نیست';
}
if (phoneNumber && !IRAN_MOBILE.test(phoneNumber)) {
details.phoneNumber = 'شماره موبایل معتبر نیست';
}
if (email && !EMAIL_RE.test(email)) {
details.email = 'ایمیل معتبر نیست';
}
if (!phoneNumber && !email) {
details.contact = 'شماره تلفن یا ایمیل الزامی است';
}
if (message.length > 2000) {
details.message = 'پیام نباید بیش از ۲۰۰۰ کاراکتر باشد';
}
const allowed = ContactInquiry.CONTACT_METHODS;
const invalidMethods = preferredContactMethods.filter((m) => !allowed.includes(m));
if (!preferredContactMethods.length) {
details.preferredContactMethods = 'حداقل یک روش تماس را انتخاب کنید';
} else if (invalidMethods.length) {
details.preferredContactMethods = 'روش تماس نامعتبر است';
}
if (courses.some((id) => typeof id !== 'string' && typeof id !== 'number')) {
details.courses = 'شناسه دوره نامعتبر است';
}
if (Object.keys(details).length) {
return next(new AppError('VALIDATION_FAILED', details));
}
req.body = {
name,
surname,
nationalIdCode: nationalIdCode || undefined,
phoneNumber: phoneNumber || undefined,
email: email || undefined,
message: message || undefined,
preferredContactMethods,
courses
};
return next();
};
module.exports = {
validateCreateInquiry
};