From e34fd25af7b83c7cbd34f89a1f1f2ae3085c411f Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Fri, 21 Aug 2026 13:19:50 +0330 Subject: [PATCH] feat: add soft delete for classes with cascade to sessions and linked records --- components/classes/classController.js | 5 ++ components/classes/classModel.js | 9 +++ components/classes/classRoutes.js | 1 + components/classes/classService.js | 54 +++++++++++----- components/classes/classSoftDelete.test.js | 62 +++++++++++++++++++ components/dashboard/dashboardService.js | 6 +- .../financialReportService.js | 10 +-- components/payments/paymentModel.js | 11 +++- components/payments/paymentService.js | 5 ++ .../pendingStudents/pendingStudentModel.js | 11 +++- .../pendingStudents/pendingStudentService.js | 5 ++ components/sessions/sessionModel.js | 9 +++ components/sessions/sessionService.js | 21 +++++-- jobs/classReminderJob.js | 6 +- package.json | 2 +- 15 files changed, 184 insertions(+), 33 deletions(-) create mode 100644 components/classes/classSoftDelete.test.js diff --git a/components/classes/classController.js b/components/classes/classController.js index 8dd9062..04340fd 100644 --- a/components/classes/classController.js +++ b/components/classes/classController.js @@ -30,6 +30,11 @@ exports.delete = catchAsync(async (req, res) => { return successResponse(res, 200, 'Class deleted successfully'); }); +exports.restore = catchAsync(async (req, res) => { + const cls = await classService.restore(req.params.id); + return successResponse(res, 200, 'Class restored successfully', cls); +}); + exports.registerUsers = catchAsync(async (req, res) => { const cls = await classService.registerUsers(req.params.id, req.body.userIds || [], req.body); return successResponse(res, 200, 'Users registered in class successfully', cls); diff --git a/components/classes/classModel.js b/components/classes/classModel.js index b1428d6..ba92141 100644 --- a/components/classes/classModel.js +++ b/components/classes/classModel.js @@ -108,6 +108,15 @@ const classSchema = new mongoose.Schema({ type: Boolean, default: true }, + isDeleted: { + type: Boolean, + default: false, + index: true + }, + deletedAt: { + type: Date, + default: null + }, adminNotes: { type: [String], default: [] diff --git a/components/classes/classRoutes.js b/components/classes/classRoutes.js index 8181bc0..027cb9e 100644 --- a/components/classes/classRoutes.js +++ b/components/classes/classRoutes.js @@ -24,6 +24,7 @@ router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.CLASSES_READ), classC router.post('/admin/create', perm.requires(PERMISSIONS.CLASSES_CREATE), classController.create); router.put('/admin/update/:id', perm.requires(PERMISSIONS.CLASSES_UPDATE), classController.update); router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.CLASSES_DELETE), classController.delete); +router.post('/admin/restore/:id', perm.requires(PERMISSIONS.CLASSES_DELETE), classController.restore); router.post('/admin/:id/register-users', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.registerUsers); router.delete('/admin/:id/students/:userId', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.removeUser); diff --git a/components/classes/classService.js b/components/classes/classService.js index b6c9582..4052266 100644 --- a/components/classes/classService.js +++ b/components/classes/classService.js @@ -4,6 +4,8 @@ const Class = require('./classModel'); const User = require('../users/userModel'); const Session = require('../sessions/sessionModel'); +const PendingStudent = require('../pendingStudents/pendingStudentModel'); +const Payment = require('../payments/paymentModel'); const AppError = require('../../utils/AppError'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages'); @@ -19,7 +21,7 @@ const applyScheduleFields = (payload, body) => { return payload; }; -const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions payoutType payoutPercentage payoutHourlyRate extraExpensePerSession isActive adminNotes createdAt updatedAt'; +const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions payoutType payoutPercentage payoutHourlyRate extraExpensePerSession isActive isDeleted deletedAt adminNotes createdAt updatedAt'; const normalizePricingFields = (body = {}) => { const tuitionFee = Math.max(0, Number(body.tuitionFee) || 0); @@ -56,12 +58,18 @@ const enrichClassForDisplay = (cls) => { return { ...cls, finalTuitionFee, daysUntilStart }; }; -const getAll = async (query) => { - const page = parseInt(query.page) || 1; - const limit = Math.min(parseInt(query.limit) || 20, 200); +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.isActive !== undefined) filter.isActive = query.isActive === 'true'; @@ -130,8 +138,24 @@ const update = async (id, body) => { }; const remove = async (id) => { - const cls = await Class.findByIdAndDelete(id); + const cls = await Class.findById(id); if (!cls) throw new AppError('CLASS_NOT_FOUND'); + const now = new Date(); + await Class.findByIdAndUpdate(id, { $set: { isDeleted: true, deletedAt: now } }); + await Session.updateMany({ class: id, isDeleted: { $ne: true } }, { $set: { isDeleted: true, deletedAt: now } }); + await PendingStudent.updateMany({ class: id, isDeleted: { $ne: true } }, { $set: { isDeleted: true, deletedAt: now } }); + await Payment.updateMany({ classes: id, isDeleted: { $ne: true } }, { $set: { isDeleted: true, deletedAt: now } }); + return { success: true }; +}; + +const restore = async (id) => { + const cls = await Class.findById(id); + if (!cls) throw new AppError('CLASS_NOT_FOUND'); + await Class.findByIdAndUpdate(id, { $set: { isDeleted: false, deletedAt: null } }); + await Session.updateMany({ class: id, isDeleted: true }, { $set: { isDeleted: false, deletedAt: null } }); + await PendingStudent.updateMany({ class: id, isDeleted: true }, { $set: { isDeleted: false, deletedAt: null } }); + await Payment.updateMany({ classes: id, isDeleted: true }, { $set: { isDeleted: false, deletedAt: null } }); + return getOne(id); }; const registerUsers = async (classId, userIds, notifyInput = {}) => { @@ -151,7 +175,7 @@ const registerUsers = async (classId, userIds, notifyInput = {}) => { if (notify.sms || notify.email || notify.bot) { const classLabel = cls.name || cls.course?.title || 'کلاس'; - const sessions = await Session.find({ class: classId }) + const sessions = await Session.find({ class: classId, isDeleted: { $ne: true } }) .select('day startTime endTime') .sort({ day: 1 }) .lean(); @@ -200,11 +224,11 @@ const removeUser = async (classId, userId) => { }; const getMyClasses = async (userId, query = {}) => { - const page = parseInt(query.page) || 1; - const limit = Math.min(parseInt(query.limit) || 20, 200); + const page = parseInt(query.page, 10) || 1; + const limit = Math.min(parseInt(query.limit, 10) || 20, 200); const skip = (page - 1) * limit; - const filter = { students: userId }; + const filter = { students: userId, isDeleted: { $ne: true } }; if (query.isActive !== undefined) filter.isActive = query.isActive === 'true'; const [items, total] = await Promise.all([ @@ -219,13 +243,14 @@ const getMyClasses = async (userId, query = {}) => { }; const getPublicClasses = async (query = {}) => { - const page = parseInt(query.page) || 1; - const limit = Math.min(parseInt(query.limit) || 50, 200); + const page = parseInt(query.page, 10) || 1; + const limit = Math.min(parseInt(query.limit, 10) || 50, 200); const skip = (page - 1) * limit; const filter = { isActive: { $ne: false }, - showOnFrontend: { $ne: false } + showOnFrontend: { $ne: false }, + isDeleted: { $ne: true } }; if (query.courseId) filter.course = query.courseId; @@ -245,7 +270,8 @@ const getPublicOne = async (id) => { const cls = await Class.findOne({ _id: id, isActive: { $ne: false }, - showOnFrontend: { $ne: false } + showOnFrontend: { $ne: false }, + isDeleted: { $ne: true } }) .select('name course professor capacity tuitionFee hasDiscount discount freeSpots startDate endDate days startTime endTime isActive') .populate({ path: 'course', select: 'title type description sectionCount hoursPerSection price' }) @@ -256,4 +282,4 @@ const getPublicOne = async (id) => { return enrichClassForDisplay(cls); }; -module.exports = { getAll, getOne, create, update, remove, registerUsers, removeUser, getMyClasses, getPublicClasses, getPublicOne }; +module.exports = { getAll, getOne, create, update, remove, restore, registerUsers, removeUser, getMyClasses, getPublicClasses, getPublicOne }; diff --git a/components/classes/classSoftDelete.test.js b/components/classes/classSoftDelete.test.js new file mode 100644 index 0000000..b175045 --- /dev/null +++ b/components/classes/classSoftDelete.test.js @@ -0,0 +1,62 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const Class = require('./classModel'); +const Session = require('../sessions/sessionModel'); +const PendingStudent = require('../pendingStudents/pendingStudentModel'); +const Payment = require('../payments/paymentModel'); + +describe('Soft delete schema configuration', () => { + it('has isDeleted and deletedAt in Class schema', () => { + const isDeletedPath = Class.schema.path('isDeleted'); + const deletedAtPath = Class.schema.path('deletedAt'); + + assert.ok(isDeletedPath, 'Class schema should have isDeleted path'); + assert.equal(isDeletedPath.instance, 'Boolean'); + assert.equal(isDeletedPath.defaultValue, false); + + assert.ok(deletedAtPath, 'Class schema should have deletedAt path'); + assert.equal(deletedAtPath.instance, 'Date'); + assert.equal(deletedAtPath.defaultValue, null); + }); + + it('has isDeleted and deletedAt in Session schema', () => { + const isDeletedPath = Session.schema.path('isDeleted'); + const deletedAtPath = Session.schema.path('deletedAt'); + + assert.ok(isDeletedPath, 'Session schema should have isDeleted path'); + assert.equal(isDeletedPath.instance, 'Boolean'); + assert.equal(isDeletedPath.defaultValue, false); + + assert.ok(deletedAtPath, 'Session schema should have deletedAt path'); + assert.equal(deletedAtPath.instance, 'Date'); + assert.equal(deletedAtPath.defaultValue, null); + }); + + it('has isDeleted and deletedAt in PendingStudent schema', () => { + const isDeletedPath = PendingStudent.schema.path('isDeleted'); + const deletedAtPath = PendingStudent.schema.path('deletedAt'); + + assert.ok(isDeletedPath, 'PendingStudent schema should have isDeleted path'); + assert.equal(isDeletedPath.instance, 'Boolean'); + assert.equal(isDeletedPath.defaultValue, false); + + assert.ok(deletedAtPath, 'PendingStudent schema should have deletedAt path'); + assert.equal(deletedAtPath.instance, 'Date'); + assert.equal(deletedAtPath.defaultValue, null); + }); + + it('has isDeleted and deletedAt in Payment schema', () => { + const isDeletedPath = Payment.schema.path('isDeleted'); + const deletedAtPath = Payment.schema.path('deletedAt'); + + assert.ok(isDeletedPath, 'Payment schema should have isDeleted path'); + assert.equal(isDeletedPath.instance, 'Boolean'); + assert.equal(isDeletedPath.defaultValue, false); + + assert.ok(deletedAtPath, 'Payment schema should have deletedAt path'); + assert.equal(deletedAtPath.instance, 'Date'); + assert.equal(deletedAtPath.defaultValue, null); + }); +}); diff --git a/components/dashboard/dashboardService.js b/components/dashboard/dashboardService.js index 91eb65c..a8a5a16 100644 --- a/components/dashboard/dashboardService.js +++ b/components/dashboard/dashboardService.js @@ -24,8 +24,8 @@ const getAdminStats = async () => { Professor.countDocuments({}), Professor.countDocuments({ isActive: true }), Course.countDocuments({}), - Session.countDocuments({}), - Session.find({}) + Session.countDocuments({ isDeleted: { $ne: true } }), + Session.find({ isDeleted: { $ne: true } }) .sort({ day: -1 }) .limit(8) .populate('course', 'title type') @@ -75,7 +75,7 @@ const getAdminStats = async () => { // Daily sessions trend (same window) const dailySessionsRaw = await Session.aggregate([ - { $match: { createdAt: { $gte: rangeStart } } }, + { $match: { isDeleted: { $ne: true }, createdAt: { $gte: rangeStart } } }, { $group: { _id: { diff --git a/components/financialReports/financialReportService.js b/components/financialReports/financialReportService.js index bc14f30..dde919c 100644 --- a/components/financialReports/financialReportService.js +++ b/components/financialReports/financialReportService.js @@ -33,7 +33,7 @@ const normalizeId = (value) => { /** Revenue collected/owed for a set of classIds, derived from Payment records (one payment ~ one class enrollment). */ const getRevenueByClass = async (classIds) => { - const payments = await Payment.find({ classes: { $in: classIds } }) + const payments = await Payment.find({ classes: { $in: classIds }, isDeleted: { $ne: true } }) .select('classes amount discount paidAmount') .lean(); @@ -53,7 +53,7 @@ const getRevenueByClass = async (classIds) => { }; const getSessionCountsByClass = async (classIds) => { - const sessions = await Session.find({ class: { $in: classIds } }).select('class status').lean(); + const sessions = await Session.find({ class: { $in: classIds }, isDeleted: { $ne: true } }).select('class status').lean(); const byClass = new Map(classIds.map((id) => [String(id), { total: 0, held: 0, scheduled: 0, cancelled: 0 }])); for (const session of sessions) { const key = String(session.class); @@ -224,7 +224,7 @@ const getSessionReport = async (sessionId) => { const getRangeReport = async (query = {}) => { const { start, end } = resolveDateRange(query); - const classFilter = { isActive: { $ne: false } }; + const classFilter = { isActive: { $ne: false }, isDeleted: { $ne: true } }; if (query.classId) classFilter._id = query.classId; const classes = await Class.find(classFilter) @@ -248,7 +248,7 @@ const getRangeReport = async (query = {}) => { }; } - const sessions = await Session.find({ class: { $in: classIds }, day: { $gte: start, $lte: end } }) + const sessions = await Session.find({ class: { $in: classIds }, day: { $gte: start, $lte: end }, isDeleted: { $ne: true } }) .select('class status day') .lean(); @@ -259,7 +259,7 @@ const getRangeReport = async (query = {}) => { heldByClass.set(key, (heldByClass.get(key) || 0) + 1); } - const payments = await Payment.find({ classes: { $in: classIds } }).select('_id classes').lean(); + const payments = await Payment.find({ classes: { $in: classIds }, isDeleted: { $ne: true } }).select('_id classes').lean(); const paymentClassByPaymentId = new Map(); const paymentIds = []; for (const payment of payments) { diff --git a/components/payments/paymentModel.js b/components/payments/paymentModel.js index 939aadb..1afeb86 100644 --- a/components/payments/paymentModel.js +++ b/components/payments/paymentModel.js @@ -45,7 +45,16 @@ const paymentSchema = new mongoose.Schema({ enum: ['pending', 'partial', 'paid', 'overdue'], default: 'pending' }, - notes: { type: String, trim: true, maxlength: 5000 } + notes: { type: String, trim: true, maxlength: 5000 }, + isDeleted: { + type: Boolean, + default: false, + index: true + }, + deletedAt: { + type: Date, + default: null + } }, { timestamps: true, toJSON: { virtuals: true }, diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index 9234a1f..35ba5cc 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -253,6 +253,11 @@ const getAllPayments = async (query) => { const skip = (page - 1) * limit; const filter = {}; + if (query.trash === 'true' || query.isDeleted === 'true') { + filter.isDeleted = true; + } else { + filter.isDeleted = { $ne: true }; + } if (query.userId) filter.user = query.userId; if (query.status) filter.status = query.status; diff --git a/components/pendingStudents/pendingStudentModel.js b/components/pendingStudents/pendingStudentModel.js index ea952c2..930edf5 100644 --- a/components/pendingStudents/pendingStudentModel.js +++ b/components/pendingStudents/pendingStudentModel.js @@ -51,7 +51,16 @@ const pendingStudentSchema = new mongoose.Schema({ paymentReference: { type: String, trim: true }, adminNotes: { type: String, trim: true, maxlength: 2000 }, reviewedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, - reviewedAt: { type: Date } + reviewedAt: { type: Date }, + isDeleted: { + type: Boolean, + default: false, + index: true + }, + deletedAt: { + type: Date, + default: null + } }, { timestamps: true }); diff --git a/components/pendingStudents/pendingStudentService.js b/components/pendingStudents/pendingStudentService.js index c6c7404..be358ed 100644 --- a/components/pendingStudents/pendingStudentService.js +++ b/components/pendingStudents/pendingStudentService.js @@ -256,6 +256,11 @@ const getAllPendingStudents = async (query = {}) => { const skip = (page - 1) * limit; const filter = {}; + if (query.trash === 'true' || query.isDeleted === 'true') { + filter.isDeleted = true; + } else { + filter.isDeleted = { $ne: true }; + } if (query.status) filter.status = query.status; if (query.type) filter.type = query.type; if (query.classId) filter.class = query.classId; diff --git a/components/sessions/sessionModel.js b/components/sessions/sessionModel.js index 8c878fb..fdbf405 100644 --- a/components/sessions/sessionModel.js +++ b/components/sessions/sessionModel.js @@ -84,6 +84,15 @@ const sessionSchema = new mongoose.Schema({ adminNotes: { type: [String], default: [] + }, + isDeleted: { + type: Boolean, + default: false, + index: true + }, + deletedAt: { + type: Date, + default: null } }, { timestamps: true diff --git a/components/sessions/sessionService.js b/components/sessions/sessionService.js index 1b6b75d..1b647e3 100644 --- a/components/sessions/sessionService.js +++ b/components/sessions/sessionService.js @@ -124,7 +124,7 @@ const buildStudentsByClassId = async (sessions) => { )]; if (!classIds.length) return new Map(); - const classes = await Class.find({ _id: { $in: classIds } }).select('students').lean(); + const classes = await Class.find({ _id: { $in: classIds }, isDeleted: { $ne: true } }).select('students').lean(); const allStudentIds = [...new Set( classes.flatMap((cls) => (cls.students || []).map(normalizeId)).filter(Boolean) )]; @@ -257,6 +257,12 @@ const getAllSessions = async (queryParams) => { } if (queryParams.professorId) filter.professor = queryParams.professorId; + if (queryParams.trash === 'true' || queryParams.isDeleted === 'true') { + filter.isDeleted = true; + } else { + filter.isDeleted = { $ne: true }; + } + if (filter.status) { filter.status = STATUS_MAP[filter.status] || filter.status; } @@ -313,7 +319,7 @@ const deleteSession = async (id) => { if (!session) { throw new AppError('SESSION_NOT_FOUND'); } - await Session.findByIdAndDelete(id); + await Session.findByIdAndUpdate(id, { $set: { isDeleted: true, deletedAt: new Date() } }); return null; }; @@ -328,8 +334,11 @@ const bulkDeleteSessions = async (ids) => { throw new AppError('VALIDATION_FAILED', { ids: 'Required' }, 'هیچ جلسه‌ای انتخاب نشده است.'); } - const result = await Session.deleteMany({ _id: { $in: sessionIds } }); - return { deletedCount: result.deletedCount || 0 }; + const result = await Session.updateMany( + { _id: { $in: sessionIds } }, + { $set: { isDeleted: true, deletedAt: new Date() } } + ); + return { deletedCount: result.modifiedCount || result.nModified || 0 }; }; const bulkUpdateSessionStatus = async (ids, status, actorId = null) => { @@ -405,10 +414,10 @@ const updateSessionAttendance = async (sessionId, attendanceList, recordedBy = n }; const getMySessions = async (userId, queryParams) => { - const userClasses = await Class.find({ students: userId }).select('_id'); + const userClasses = await Class.find({ students: userId, isDeleted: { $ne: true } }).select('_id'); const classIds = userClasses.map((c) => c._id); - const filter = { class: { $in: classIds } }; + const filter = { class: { $in: classIds }, isDeleted: { $ne: true } }; const { page, limit, skip } = parsePaginationAndSort(queryParams, 'day', 'asc'); const [matched, totalCount] = await Promise.all([ diff --git a/jobs/classReminderJob.js b/jobs/classReminderJob.js index de46434..e894efe 100644 --- a/jobs/classReminderJob.js +++ b/jobs/classReminderJob.js @@ -38,8 +38,9 @@ const runClassReminderJob = async () => { status: 'scheduled', reminderSentAt: null, day: { $gte: dayFrom, $lte: dayTo }, + isDeleted: { $ne: true } }) - .populate({ path: 'class', select: 'name students startDate days startTime endTime uniqueCode' }) + .populate({ path: 'class', select: 'name students startDate days startTime endTime uniqueCode isDeleted' }) .populate({ path: 'course', select: 'title' }) .lean(); @@ -48,6 +49,7 @@ const runClassReminderJob = async () => { if (!startAt || startAt < windowStart || startAt > windowEnd) continue; const classDoc = session.class; + if (!classDoc || classDoc.isDeleted) continue; const studentIds = classDoc?.students || []; if (studentIds.length === 0) { await Session.updateOne({ _id: session._id }, { reminderSentAt: new Date() }); @@ -58,7 +60,7 @@ const runClassReminderJob = async () => { const timeLabel = session.startTime; const placeLabel = session.place || '-'; const classSessions = classDoc?._id - ? await Session.find({ class: classDoc._id }).select('day startTime endTime').sort({ day: 1 }).lean() + ? await Session.find({ class: classDoc._id, isDeleted: { $ne: true } }).select('day startTime endTime').sort({ day: 1 }).lean() : []; const schedule = buildClassScheduleContext(classDoc, classSessions, session); diff --git a/package.json b/package.json index 8f494b4..24311c0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node app.js", "dev": "nodemon app.js", "seed": "node seed.js", - "test": "node --test components/settings/smsTemplates.test.js components/users/passwordReset.test.js components/dataImport/importHelpers.test.js utils/classSchedule.test.js utils/jalaliDate.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js utils/professorShare.test.js utils/financialRange.test.js" + "test": "node --test components/classes/classSoftDelete.test.js components/settings/smsTemplates.test.js components/users/passwordReset.test.js components/dataImport/importHelpers.test.js utils/classSchedule.test.js utils/jalaliDate.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js utils/professorShare.test.js utils/financialRange.test.js" }, "keywords": [ "express",