diff --git a/components/classes/classModel.js b/components/classes/classModel.js index eda3967..2c406be 100644 --- a/components/classes/classModel.js +++ b/components/classes/classModel.js @@ -36,6 +36,24 @@ const classSchema = new mongoose.Schema({ endDate: { type: Date }, + days: { + type: [{ + type: Number, + min: 0, + max: 6 + }], + default: [] + }, + startTime: { + type: String, + trim: true, + default: '' + }, + endTime: { + type: String, + trim: true, + default: '' + }, isActive: { type: Boolean, default: true diff --git a/components/classes/classService.js b/components/classes/classService.js index 350968f..cd92177 100644 --- a/components/classes/classService.js +++ b/components/classes/classService.js @@ -7,10 +7,17 @@ const Session = require('../sessions/sessionModel'); const AppError = require('../../utils/AppError'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages'); -const { buildClassScheduleContext } = require('../../utils/classSchedule'); +const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime } = require('../../utils/classSchedule'); const { pickNotifyFlags } = require('../../utils/notifyFlags'); const logger = require('../../utils/logger'); +const applyScheduleFields = (payload, body) => { + if (body.days !== undefined) payload.days = normalizeWeekdays(body.days); + if (body.startTime !== undefined) payload.startTime = normalizeClockTime(body.startTime); + if (body.endTime !== undefined) payload.endTime = normalizeClockTime(body.endTime); + return payload; +}; + const getAll = async (query) => { const page = parseInt(query.page) || 1; const limit = Math.min(parseInt(query.limit) || 20, 200); @@ -27,7 +34,7 @@ const getAll = async (query) => { const [items, total] = await Promise.all([ Class.find(filter) - .select('name course professor students capacity tuitionFee startDate endDate isActive adminNotes createdAt updatedAt') + .select('name course professor students capacity tuitionFee startDate endDate days startTime endTime isActive adminNotes createdAt updatedAt') .populate({ path: 'course', select: 'title type price' }) .populate({ path: 'professor', select: 'name surname' }) .skip(skip).limit(limit).sort({ createdAt: -1 }).lean(), @@ -48,12 +55,14 @@ const getOne = async (id) => { }; const create = async (body) => { - const cls = await Class.create(body); + const payload = applyScheduleFields({ ...body }, body); + const cls = await Class.create(payload); return getOne(cls._id); }; const update = async (id, body) => { - const cls = await Class.findByIdAndUpdate(id, body, { new: true, runValidators: true }) + const payload = applyScheduleFields({ ...body }, body); + const cls = await Class.findByIdAndUpdate(id, payload, { new: true, runValidators: true }) .populate({ path: 'course', select: 'title' }) .lean(); if (!cls) throw new AppError('CLASS_NOT_FOUND'); diff --git a/jobs/classReminderJob.js b/jobs/classReminderJob.js index eec103e..80d413f 100644 --- a/jobs/classReminderJob.js +++ b/jobs/classReminderJob.js @@ -37,7 +37,7 @@ const runClassReminderJob = async () => { reminderSentAt: null, day: { $gte: dayFrom, $lte: dayTo }, }) - .populate({ path: 'class', select: 'name students startDate' }) + .populate({ path: 'class', select: 'name students startDate days startTime endTime' }) .populate({ path: 'course', select: 'title' }) .lean(); diff --git a/utils/classSchedule.js b/utils/classSchedule.js index 71dfb9d..bd03eca 100644 --- a/utils/classSchedule.js +++ b/utils/classSchedule.js @@ -39,6 +39,37 @@ const joinFaList = (items) => { return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`; }; +const normalizeWeekdays = (days) => { + if (!Array.isArray(days)) return []; + const unique = []; + const seen = new Set(); + for (const value of days) { + const index = Number(value); + if (!Number.isInteger(index) || index < 0 || index > 6 || seen.has(index)) continue; + seen.add(index); + unique.push(index); + } + unique.sort((a, b) => IRAN_WEEK_ORDER.indexOf(a) - IRAN_WEEK_ORDER.indexOf(b)); + return unique; +}; + +const normalizeClockTime = (value) => { + if (value == null) return ''; + const raw = String(value).trim(); + if (!raw) return ''; + const match = raw.match(/^(\d{1,2})(?::(\d{1,2}))?$/); + if (!match) return ''; + const hours = Number(match[1]); + const minutes = Number(match[2] || 0); + if (hours > 23 || minutes > 59) return ''; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; +}; + +const formatClassDaysFromIndexes = (days = []) => { + const indexes = normalizeWeekdays(days); + return joinFaList(indexes.map((index) => PERSIAN_WEEKDAYS[index]).filter(Boolean)); +}; + const formatClassDays = (sessions = []) => { const indexes = []; const seen = new Set(); @@ -48,8 +79,7 @@ const formatClassDays = (sessions = []) => { seen.add(dayIndex); indexes.push(dayIndex); } - indexes.sort((a, b) => IRAN_WEEK_ORDER.indexOf(a) - IRAN_WEEK_ORDER.indexOf(b)); - return joinFaList(indexes.map((index) => PERSIAN_WEEKDAYS[index]).filter(Boolean)); + return formatClassDaysFromIndexes(indexes); }; const formatClassStartDate = (value) => { @@ -69,17 +99,24 @@ const buildClassScheduleContext = (classDoc, sessions = [], currentSession = nul return left - right; }); const sessionForTime = currentSession || sortedSessions[0] || null; + const storedDays = formatClassDaysFromIndexes(classDoc?.days); + const storedTime = formatCourseTime(classDoc?.startTime, classDoc?.endTime); return { classStartDate: formatClassStartDate(classDoc?.startDate || sortedSessions[0]?.day), - classDays: formatClassDays(sortedSessions), - courseTime: formatCourseTime(sessionForTime?.startTime, sessionForTime?.endTime) + classDays: storedDays || formatClassDays(sortedSessions), + courseTime: storedTime || formatCourseTime(sessionForTime?.startTime, sessionForTime?.endTime) }; }; module.exports = { + IRAN_WEEK_ORDER, + PERSIAN_WEEKDAYS, formatCourseTime, formatClassDays, + formatClassDaysFromIndexes, formatClassStartDate, + normalizeWeekdays, + normalizeClockTime, buildClassScheduleContext }; diff --git a/utils/classSchedule.test.js b/utils/classSchedule.test.js index 9dd4475..98efe8d 100644 --- a/utils/classSchedule.test.js +++ b/utils/classSchedule.test.js @@ -6,6 +6,9 @@ const { formatCourseTime, formatClassDays, formatClassStartDate, + formatClassDaysFromIndexes, + normalizeWeekdays, + normalizeClockTime, buildClassScheduleContext } = require('./classSchedule'); @@ -56,4 +59,40 @@ describe('class schedule SMS helpers', () => { assert.equal(context.courseTime, 'از 10 تا 12'); }); + + it('prefers stored class days and times over inferred sessions', () => { + const atNoon = (year, month, day) => new Date(year, month - 1, day, 12, 0, 0); + const context = buildClassScheduleContext( + { + startDate: atNoon(2026, 8, 15), + days: [6, 1], + startTime: '18:00', + endTime: '20:00' + }, + [ + { day: atNoon(2026, 8, 16), startTime: '19:00', endTime: '21:00' } + ] + ); + + assert.equal(context.classDays, 'شنبه و دوشنبه'); + assert.equal(context.courseTime, 'از 18 تا 20'); + }); +}); + +describe('class weekday and time normalization', () => { + it('deduplicates weekdays and sorts them in Iran week order', () => { + assert.deepEqual(normalizeWeekdays([1, 6, 1, 3, 9, '2']), [6, 1, 2, 3]); + }); + + it('normalizes clock times to HH:mm', () => { + assert.equal(normalizeClockTime('19'), '19:00'); + assert.equal(normalizeClockTime('9:5'), '09:05'); + assert.equal(normalizeClockTime('19:00'), '19:00'); + assert.equal(normalizeClockTime('25:00'), ''); + assert.equal(normalizeClockTime(''), ''); + }); + + it('formats stored weekday indexes as Persian day names', () => { + assert.equal(formatClassDaysFromIndexes([1, 6]), 'شنبه و دوشنبه'); + }); });