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
+48
View File
@@ -0,0 +1,48 @@
// /components/payments/paymentController.js
const catchAsync = require('../../utils/catchAsync');
const paymentService = require('./paymentService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.createPayment(req.body, actorId);
return successResponse(res, 201, 'Payment created successfully', payment);
});
exports.getOne = catchAsync(async (req, res, next) => {
const payment = await paymentService.getPaymentById(req.params.id);
return successResponse(res, 200, 'Payment retrieved successfully', payment);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await paymentService.getAllPayments(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.updatePayment(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Payment updated successfully', payment);
});
exports.delete = catchAsync(async (req, res, next) => {
await paymentService.deletePayment(req.params.id);
return successResponse(res, 200, 'Payment deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await paymentService.searchPayments(req.query);
return listResponse(res, 200, data, meta);
});
exports.payUser = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.addTransaction(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Payment transaction recorded', payment);
});
exports.getMyPayments = catchAsync(async (req, res, next) => {
const { data, meta } = await paymentService.getMyPayments(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
+71
View File
@@ -0,0 +1,71 @@
// /components/payments/paymentModel.js
'use strict';
const mongoose = require('mongoose');
const transactionSchema = new mongoose.Schema({
amount: { type: Number, required: true },
method: {
type: String,
enum: ['online', 'card', 'cash'],
default: 'card'
},
receiptNumber: { type: String, trim: true },
recordedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
date: { type: Date, default: Date.now }
}, { _id: true });
const paymentSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
classes: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Class'
}],
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
},
amount: {
type: Number,
required: true,
min: 0
},
paidAmount: {
type: Number,
default: 0,
min: 0
},
dueDate: {
type: Date
},
status: {
type: String,
enum: ['pending', 'partial', 'paid', 'overdue'],
default: 'pending'
},
transactions: [transactionSchema],
notes: { type: String, trim: true }
}, {
timestamps: true
});
// Auto-update status based on paid amount
paymentSchema.pre('save', function (next) {
if (this.paidAmount >= this.amount) {
this.status = 'paid';
} else if (this.paidAmount > 0) {
this.status = 'partial';
} else if (this.dueDate && new Date() > this.dueDate) {
this.status = 'overdue';
} else {
this.status = 'pending';
}
next();
});
module.exports = mongoose.model('Payment', paymentSchema);
+30
View File
@@ -0,0 +1,30 @@
// /components/payments/paymentRoutes.js
const express = require('express');
const paymentController = require('./paymentController');
const {
validateCreatePayment,
validateUpdatePayment,
validateAddTransaction
} = require('./paymentValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-payments', paymentController.getMyPayments);
router.post('/user/pay/:id', validateAddTransaction, paymentController.payUser);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.PAYMENTS_CREATE), validateCreatePayment, paymentController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.PAYMENTS_SEARCH), paymentController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete);
module.exports = router;
+120
View File
@@ -0,0 +1,120 @@
// /components/payments/paymentService.js
'use strict';
const Payment = require('./paymentModel');
const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { calculateMeta } = require('../../utils/pagination');
const getAllPayments = async (query) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.userId) filter.user = query.userId;
if (query.status) filter.status = query.status;
const [items, total] = await Promise.all([
Payment.find(filter)
.populate({ path: 'user', select: 'name surname' })
.populate({ path: 'classes', select: 'name' })
.populate({ path: 'course', select: 'title' })
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
Payment.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
const getPaymentById = async (id) => {
const payment = await Payment.findById(id)
.populate({ path: 'user', select: 'name surname phoneNumber' })
.populate({ path: 'classes', select: 'name tuitionFee' })
.populate({ path: 'course', select: 'title price' })
.lean();
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
return payment;
};
const createPayment = async (body, actorId = null) => {
const payment = await Payment.create({
...body,
paidAmount: body.paidAmount || 0
});
if (actorId) {
eventEmitter.emit(EVENT_NAMES.PAYMENT_CREATED, {
paymentId: payment._id,
userId: payment.user,
actorId
});
}
return getPaymentById(payment._id);
};
const updatePayment = async (id, body, actorId = null) => {
const payment = await Payment.findById(id);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
const previousStatus = payment.status;
Object.assign(payment, body);
await payment.save();
if (body.status && body.status !== previousStatus) {
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
paymentId: payment._id,
userId: payment.user,
oldStatus: previousStatus,
newStatus: payment.status,
actorId
});
}
return getPaymentById(payment._id);
};
const deletePayment = async (id) => {
const payment = await Payment.findByIdAndDelete(id);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
return null;
};
const searchPayments = async (query) => getAllPayments(query);
const addTransaction = async (paymentId, trxData, actorId = null) => {
const payment = await Payment.findById(paymentId);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
payment.transactions.push({
...trxData,
recordedBy: actorId || trxData.recordedBy,
date: trxData.date || new Date()
});
payment.paidAmount = payment.transactions.reduce((sum, t) => sum + (t.amount || 0), 0);
await payment.save();
return getPaymentById(paymentId);
};
const getMyPayments = async (userId, query = {}) => {
return getAllPayments({ ...query, userId });
};
module.exports = {
getAllPayments,
getPaymentById,
createPayment,
updatePayment,
deletePayment,
searchPayments,
addTransaction,
getMyPayments,
// Aliases for older call sites
getAll: getAllPayments,
getOne: getPaymentById,
create: createPayment,
recordTransaction: addTransaction,
remove: deletePayment
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});