feat: add waitlist module, quick payment/transaction edit, and catering fee deduction from professor share
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// /components/waitlist/waitlistController.js
|
||||
'use strict';
|
||||
|
||||
const catchAsync = require('../../utils/catchAsync');
|
||||
const waitlistService = require('./waitlistService');
|
||||
const { successResponse, listResponse } = require('../../utils/apiResponse');
|
||||
|
||||
exports.getAll = catchAsync(async (req, res) => {
|
||||
const { data, meta } = await waitlistService.getAll(req.query);
|
||||
return listResponse(res, 200, data, meta);
|
||||
});
|
||||
|
||||
exports.getOne = catchAsync(async (req, res) => {
|
||||
const item = await waitlistService.getOne(req.params.id);
|
||||
return successResponse(res, 200, 'Waitlist item retrieved successfully', item);
|
||||
});
|
||||
|
||||
exports.create = catchAsync(async (req, res) => {
|
||||
const actorId = req.user?._id;
|
||||
const item = await waitlistService.create(req.body, actorId);
|
||||
return successResponse(res, 201, 'Student added to waitlist successfully', item);
|
||||
});
|
||||
|
||||
exports.update = catchAsync(async (req, res) => {
|
||||
const actorId = req.user?._id;
|
||||
const item = await waitlistService.update(req.params.id, req.body, actorId);
|
||||
return successResponse(res, 200, 'Waitlist item updated successfully', item);
|
||||
});
|
||||
|
||||
exports.assignClass = catchAsync(async (req, res) => {
|
||||
const actorId = req.user?._id;
|
||||
const item = await waitlistService.assignToClass(req.params.id, req.body, actorId);
|
||||
return successResponse(res, 200, 'Student assigned to class successfully', item);
|
||||
});
|
||||
|
||||
exports.revert = catchAsync(async (req, res) => {
|
||||
const actorId = req.user?._id;
|
||||
const item = await waitlistService.revert(req.params.id, req.body, actorId);
|
||||
return successResponse(res, 200, 'Waitlist registration reverted successfully', item);
|
||||
});
|
||||
|
||||
exports.cancel = catchAsync(async (req, res) => {
|
||||
const actorId = req.user?._id;
|
||||
const item = await waitlistService.cancel(req.params.id, req.body, actorId);
|
||||
return successResponse(res, 200, 'Waitlist registration cancelled successfully', item);
|
||||
});
|
||||
|
||||
exports.delete = catchAsync(async (req, res) => {
|
||||
await waitlistService.remove(req.params.id);
|
||||
return successResponse(res, 200, 'Waitlist item deleted successfully');
|
||||
});
|
||||
|
||||
exports.getStats = catchAsync(async (req, res) => {
|
||||
const stats = await waitlistService.getStats();
|
||||
return successResponse(res, 200, 'Waitlist stats retrieved successfully', stats);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// /components/waitlist/waitlistModel.js
|
||||
'use strict';
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin');
|
||||
|
||||
const waitlistSchema = new mongoose.Schema({
|
||||
user: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
course: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Course',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
payment: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Payment',
|
||||
index: true
|
||||
},
|
||||
class: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Class',
|
||||
default: null,
|
||||
index: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['waiting', 'enrolled', 'cancelled', 'reverted'],
|
||||
default: 'waiting',
|
||||
index: true
|
||||
},
|
||||
adminNotes: {
|
||||
type: [String],
|
||||
default: []
|
||||
},
|
||||
registeredAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
assignedAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
isDeleted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
index: true
|
||||
},
|
||||
deletedAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
waitlistSchema.plugin(uniqueCodePlugin);
|
||||
|
||||
module.exports = mongoose.model('Waitlist', waitlistSchema);
|
||||
@@ -0,0 +1,24 @@
|
||||
// /components/waitlist/waitlistRoutes.js
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const waitlistController = require('./waitlistController');
|
||||
const authMiddleware = require('../../middlewares/authMiddleware');
|
||||
const perm = require('../../middlewares/permissionMiddleware');
|
||||
const { PERMISSIONS } = require('../../constants/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/admin/get-all', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getAll);
|
||||
router.get('/admin/stats', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getStats);
|
||||
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getOne);
|
||||
router.post('/admin/create', perm.requires(PERMISSIONS.WAITLIST_CREATE), waitlistController.create);
|
||||
router.put('/admin/update/:id', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.update);
|
||||
router.post('/admin/:id/assign-class', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.assignClass);
|
||||
router.post('/admin/:id/revert', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.revert);
|
||||
router.post('/admin/:id/cancel', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.cancel);
|
||||
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.WAITLIST_DELETE), waitlistController.delete);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,303 @@
|
||||
// /components/waitlist/waitlistService.js
|
||||
'use strict';
|
||||
|
||||
const Waitlist = require('./waitlistModel');
|
||||
const User = require('../users/userModel');
|
||||
const Course = require('../courses/courseModel');
|
||||
const Class = require('../classes/classModel');
|
||||
const Payment = require('../payments/paymentModel');
|
||||
const Transaction = require('../payments/transactionModel');
|
||||
const AppError = require('../../utils/AppError');
|
||||
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
|
||||
const {
|
||||
getPayableAmount,
|
||||
normalizeDiscount,
|
||||
sanitizeNotes,
|
||||
sumPaidTransactions
|
||||
} = require('../../utils/paymentAmount');
|
||||
const paymentService = require('../payments/paymentService');
|
||||
|
||||
const populateWaitlist = (query) => query
|
||||
.populate({ path: 'user', select: 'name phoneNumber email nationalIdCode' })
|
||||
.populate({ path: 'course', select: 'title price code' })
|
||||
.populate({ path: 'class', select: 'name tuitionFee startDate days startTime endTime' })
|
||||
.populate({
|
||||
path: 'payment',
|
||||
populate: {
|
||||
path: 'transactions',
|
||||
options: { sort: { dueDate: 1, date: 1, createdAt: 1 } }
|
||||
}
|
||||
});
|
||||
|
||||
const getAll = async (query = {}) => {
|
||||
const page = parseInt(query.page, 10) || 1;
|
||||
const limit = Math.min(parseInt(query.limit, 10) || 20, 200);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const filter = {};
|
||||
if (query.trash === 'true' || query.isDeleted === 'true') {
|
||||
filter.isDeleted = true;
|
||||
} else {
|
||||
filter.isDeleted = { $ne: true };
|
||||
}
|
||||
|
||||
if (query.courseId) filter.course = query.courseId;
|
||||
if (query.userId) filter.user = query.userId;
|
||||
if (query.status) filter.status = query.status;
|
||||
|
||||
const searchTerm = getSearchTerm(query);
|
||||
if (searchTerm) {
|
||||
const searchRegex = new RegExp(escapeRegex(searchTerm), 'i');
|
||||
const matchedUsers = await User.find({
|
||||
$or: [
|
||||
{ name: searchRegex },
|
||||
{ phoneNumber: searchRegex },
|
||||
{ nationalIdCode: searchRegex }
|
||||
]
|
||||
}).select('_id').lean();
|
||||
|
||||
filter.$or = [
|
||||
{ uniqueCode: searchRegex },
|
||||
{ adminNotes: searchRegex },
|
||||
{ user: { $in: matchedUsers.map((u) => u._id) } }
|
||||
];
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
populateWaitlist(Waitlist.find(filter))
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.sort({ createdAt: -1 })
|
||||
.lean(),
|
||||
Waitlist.countDocuments(filter)
|
||||
]);
|
||||
|
||||
return { data: items, meta: calculateMeta(total, page, limit) };
|
||||
};
|
||||
|
||||
const getOne = async (id) => {
|
||||
const item = await populateWaitlist(Waitlist.findById(id)).lean();
|
||||
if (!item) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
|
||||
return item;
|
||||
};
|
||||
|
||||
const create = async (body, actorId = null) => {
|
||||
const { userId, courseId, amount, discount, dueDate, notes, initialTransaction, adminNotes } = body;
|
||||
|
||||
const user = await User.findById(userId || body.user);
|
||||
if (!user) throw new AppError('USER_NOT_FOUND', {}, 'کاربر مورد نظر یافت نشد.');
|
||||
|
||||
const course = await Course.findById(courseId || body.course);
|
||||
if (!course) throw new AppError('COURSE_NOT_FOUND', {}, 'دوره آموزشی مورد نظر یافت نشد.');
|
||||
|
||||
const totalAmount = amount !== undefined && amount !== null && amount !== ''
|
||||
? Number(amount)
|
||||
: (course.price || 0);
|
||||
|
||||
const totalDiscount = discount !== undefined && discount !== null && discount !== ''
|
||||
? normalizeDiscount(Number(discount), totalAmount)
|
||||
: 0;
|
||||
|
||||
// Create waiting_list payment
|
||||
const payment = await Payment.create({
|
||||
user: user._id,
|
||||
course: course._id,
|
||||
classes: [],
|
||||
amount: totalAmount,
|
||||
discount: totalDiscount,
|
||||
dueDate: dueDate ? new Date(dueDate) : undefined,
|
||||
type: 'waiting_list',
|
||||
status: 'pending',
|
||||
notes: sanitizeNotes(notes || `صورتحساب ثبتنام لیست انتظار دوره ${course.title}`)
|
||||
});
|
||||
|
||||
// Create initial transaction if supplied
|
||||
if (initialTransaction && (Number(initialTransaction.amount) > 0 || initialTransaction.status === 'paid')) {
|
||||
const trxStatus = initialTransaction.status || 'paid';
|
||||
const trxDate = trxStatus === 'paid' ? (initialTransaction.date ? new Date(initialTransaction.date) : new Date()) : undefined;
|
||||
const trxDueDate = initialTransaction.dueDate ? new Date(initialTransaction.dueDate) : (trxDate || new Date());
|
||||
|
||||
await Transaction.create({
|
||||
payment: payment._id,
|
||||
user: user._id,
|
||||
amount: Number(initialTransaction.amount) || (totalAmount - totalDiscount),
|
||||
status: trxStatus,
|
||||
method: initialTransaction.method || 'card',
|
||||
receiptNumber: initialTransaction.receiptNumber ? String(initialTransaction.receiptNumber) : '',
|
||||
date: trxDate,
|
||||
dueDate: trxDueDate,
|
||||
notes: sanitizeNotes(initialTransaction.notes || 'پرداخت بیعانه / ثبتنام لیست انتظار'),
|
||||
recordedBy: actorId
|
||||
});
|
||||
|
||||
await paymentService.refreshPaymentTotals(payment);
|
||||
}
|
||||
|
||||
const notesList = [];
|
||||
if (Array.isArray(adminNotes)) {
|
||||
notesList.push(...adminNotes.filter(Boolean));
|
||||
} else if (notes) {
|
||||
notesList.push(String(notes));
|
||||
}
|
||||
|
||||
const waitlist = await Waitlist.create({
|
||||
user: user._id,
|
||||
course: course._id,
|
||||
payment: payment._id,
|
||||
status: 'waiting',
|
||||
adminNotes: notesList,
|
||||
registeredAt: body.registeredAt ? new Date(body.registeredAt) : new Date()
|
||||
});
|
||||
|
||||
return getOne(waitlist._id);
|
||||
};
|
||||
|
||||
const update = async (id, body) => {
|
||||
const waitlist = await Waitlist.findById(id);
|
||||
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
|
||||
|
||||
if (body.adminNotes !== undefined) {
|
||||
waitlist.adminNotes = Array.isArray(body.adminNotes)
|
||||
? body.adminNotes.filter(Boolean)
|
||||
: [String(body.adminNotes)];
|
||||
}
|
||||
if (body.status !== undefined) {
|
||||
waitlist.status = body.status;
|
||||
}
|
||||
if (body.registeredAt !== undefined) {
|
||||
waitlist.registeredAt = new Date(body.registeredAt);
|
||||
}
|
||||
|
||||
await waitlist.save();
|
||||
return getOne(waitlist._id);
|
||||
};
|
||||
|
||||
const assignToClass = async (id, { classId }, actorId = null) => {
|
||||
const waitlist = await Waitlist.findById(id);
|
||||
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
|
||||
|
||||
const targetClass = await Class.findById(classId).populate('course', 'title price');
|
||||
if (!targetClass) throw new AppError('CLASS_NOT_FOUND', {}, 'کلاس مورد نظر یافت نشد.');
|
||||
|
||||
// 1. Add user to class students list if not present
|
||||
const studentIdStr = String(waitlist.user);
|
||||
const alreadyEnrolled = (targetClass.students || []).some((s) => String(s) === studentIdStr);
|
||||
if (!alreadyEnrolled) {
|
||||
targetClass.students.push(waitlist.user);
|
||||
await targetClass.save();
|
||||
}
|
||||
|
||||
// 2. Transition Payment to regular and link class
|
||||
if (waitlist.payment) {
|
||||
const payment = await Payment.findById(waitlist.payment);
|
||||
if (payment) {
|
||||
payment.classes = [targetClass._id];
|
||||
payment.type = 'regular';
|
||||
await payment.save();
|
||||
await paymentService.refreshPaymentTotals(payment);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mark waitlist status as enrolled
|
||||
waitlist.class = targetClass._id;
|
||||
waitlist.status = 'enrolled';
|
||||
waitlist.assignedAt = new Date();
|
||||
await waitlist.save();
|
||||
|
||||
return getOne(waitlist._id);
|
||||
};
|
||||
|
||||
const revert = async (id, { notes } = {}, actorId = null) => {
|
||||
const waitlist = await Waitlist.findById(id);
|
||||
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
|
||||
|
||||
waitlist.status = 'reverted';
|
||||
if (notes) {
|
||||
waitlist.adminNotes.push(`استرداد: ${notes}`);
|
||||
}
|
||||
await waitlist.save();
|
||||
|
||||
if (waitlist.payment) {
|
||||
const payment = await Payment.findById(waitlist.payment);
|
||||
if (payment) {
|
||||
payment.status = 'reverted';
|
||||
await payment.save();
|
||||
|
||||
const transactions = await Transaction.find({ payment: payment._id });
|
||||
for (const trx of transactions) {
|
||||
trx.status = 'reverted';
|
||||
if (actorId) trx.recordedBy = actorId;
|
||||
await trx.save();
|
||||
}
|
||||
|
||||
await paymentService.refreshPaymentTotals(payment);
|
||||
}
|
||||
}
|
||||
|
||||
return getOne(waitlist._id);
|
||||
};
|
||||
|
||||
const cancel = async (id, { notes } = {}, actorId = null) => {
|
||||
const waitlist = await Waitlist.findById(id);
|
||||
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
|
||||
|
||||
waitlist.status = 'cancelled';
|
||||
if (notes) {
|
||||
waitlist.adminNotes.push(`لغو: ${notes}`);
|
||||
}
|
||||
await waitlist.save();
|
||||
|
||||
if (waitlist.payment) {
|
||||
const payment = await Payment.findById(waitlist.payment);
|
||||
if (payment) {
|
||||
payment.status = 'cancelled';
|
||||
await payment.save();
|
||||
|
||||
const transactions = await Transaction.find({ payment: payment._id });
|
||||
for (const trx of transactions) {
|
||||
trx.status = 'cancelled';
|
||||
if (actorId) trx.recordedBy = actorId;
|
||||
await trx.save();
|
||||
}
|
||||
|
||||
await paymentService.refreshPaymentTotals(payment);
|
||||
}
|
||||
}
|
||||
|
||||
return getOne(waitlist._id);
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
const waitlist = await Waitlist.findById(id);
|
||||
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
|
||||
|
||||
waitlist.isDeleted = true;
|
||||
waitlist.deletedAt = new Date();
|
||||
await waitlist.save();
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const getStats = async () => {
|
||||
const [total, waiting, enrolled, reverted, cancelled] = await Promise.all([
|
||||
Waitlist.countDocuments({ isDeleted: { $ne: true } }),
|
||||
Waitlist.countDocuments({ status: 'waiting', isDeleted: { $ne: true } }),
|
||||
Waitlist.countDocuments({ status: 'enrolled', isDeleted: { $ne: true } }),
|
||||
Waitlist.countDocuments({ status: 'reverted', isDeleted: { $ne: true } }),
|
||||
Waitlist.countDocuments({ status: 'cancelled', isDeleted: { $ne: true } })
|
||||
]);
|
||||
|
||||
return { total, waiting, enrolled, reverted, cancelled };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAll,
|
||||
getOne,
|
||||
create,
|
||||
update,
|
||||
assignToClass,
|
||||
revert,
|
||||
cancel,
|
||||
remove,
|
||||
getStats
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const waitlistService = require('./waitlistService');
|
||||
|
||||
const Waitlist = require('./waitlistModel');
|
||||
|
||||
describe('Waitlist Service and Schema', () => {
|
||||
it('exports all expected service methods', () => {
|
||||
assert.equal(typeof waitlistService.getAll, 'function');
|
||||
assert.equal(typeof waitlistService.getOne, 'function');
|
||||
assert.equal(typeof waitlistService.create, 'function');
|
||||
assert.equal(typeof waitlistService.update, 'function');
|
||||
assert.equal(typeof waitlistService.assignToClass, 'function');
|
||||
assert.equal(typeof waitlistService.revert, 'function');
|
||||
assert.equal(typeof waitlistService.cancel, 'function');
|
||||
assert.equal(typeof waitlistService.remove, 'function');
|
||||
assert.equal(typeof waitlistService.getStats, 'function');
|
||||
});
|
||||
|
||||
it('has valid schema paths in Waitlist model', () => {
|
||||
assert.ok(Waitlist.schema.path('user'));
|
||||
assert.ok(Waitlist.schema.path('course'));
|
||||
assert.ok(Waitlist.schema.path('payment'));
|
||||
assert.ok(Waitlist.schema.path('class'));
|
||||
assert.ok(Waitlist.schema.path('status'));
|
||||
assert.ok(Waitlist.schema.path('isDeleted'));
|
||||
assert.ok(Waitlist.schema.path('deletedAt'));
|
||||
|
||||
const statusPath = Waitlist.schema.path('status');
|
||||
assert.deepEqual(statusPath.enumValues, ['waiting', 'enrolled', 'cancelled', 'reverted']);
|
||||
assert.equal(statusPath.defaultValue, 'waiting');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user