Files
gameno-api/components/waitlist/waitlistService.js
T

304 lines
9.8 KiB
JavaScript

// /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
};