feat(classes): store weekdays and meeting times on the class
Keep the class schedule on the class itself so SMS and listings do not have to infer days and hours from sessions.
This commit is contained in:
@@ -36,6 +36,24 @@ const classSchema = new mongoose.Schema({
|
|||||||
endDate: {
|
endDate: {
|
||||||
type: Date
|
type: Date
|
||||||
},
|
},
|
||||||
|
days: {
|
||||||
|
type: [{
|
||||||
|
type: Number,
|
||||||
|
min: 0,
|
||||||
|
max: 6
|
||||||
|
}],
|
||||||
|
default: []
|
||||||
|
},
|
||||||
|
startTime: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
endTime: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
isActive: {
|
isActive: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ const Session = require('../sessions/sessionModel');
|
|||||||
const AppError = require('../../utils/AppError');
|
const AppError = require('../../utils/AppError');
|
||||||
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
|
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
|
||||||
const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages');
|
const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages');
|
||||||
const { buildClassScheduleContext } = require('../../utils/classSchedule');
|
const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime } = require('../../utils/classSchedule');
|
||||||
const { pickNotifyFlags } = require('../../utils/notifyFlags');
|
const { pickNotifyFlags } = require('../../utils/notifyFlags');
|
||||||
const logger = require('../../utils/logger');
|
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 getAll = async (query) => {
|
||||||
const page = parseInt(query.page) || 1;
|
const page = parseInt(query.page) || 1;
|
||||||
const limit = Math.min(parseInt(query.limit) || 20, 200);
|
const limit = Math.min(parseInt(query.limit) || 20, 200);
|
||||||
@@ -27,7 +34,7 @@ const getAll = async (query) => {
|
|||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
Class.find(filter)
|
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: 'course', select: 'title type price' })
|
||||||
.populate({ path: 'professor', select: 'name surname' })
|
.populate({ path: 'professor', select: 'name surname' })
|
||||||
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
|
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
|
||||||
@@ -48,12 +55,14 @@ const getOne = async (id) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const create = async (body) => {
|
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);
|
return getOne(cls._id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const update = async (id, body) => {
|
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' })
|
.populate({ path: 'course', select: 'title' })
|
||||||
.lean();
|
.lean();
|
||||||
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const runClassReminderJob = async () => {
|
|||||||
reminderSentAt: null,
|
reminderSentAt: null,
|
||||||
day: { $gte: dayFrom, $lte: dayTo },
|
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' })
|
.populate({ path: 'course', select: 'title' })
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
|
|||||||
+41
-4
@@ -39,6 +39,37 @@ const joinFaList = (items) => {
|
|||||||
return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`;
|
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 formatClassDays = (sessions = []) => {
|
||||||
const indexes = [];
|
const indexes = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -48,8 +79,7 @@ const formatClassDays = (sessions = []) => {
|
|||||||
seen.add(dayIndex);
|
seen.add(dayIndex);
|
||||||
indexes.push(dayIndex);
|
indexes.push(dayIndex);
|
||||||
}
|
}
|
||||||
indexes.sort((a, b) => IRAN_WEEK_ORDER.indexOf(a) - IRAN_WEEK_ORDER.indexOf(b));
|
return formatClassDaysFromIndexes(indexes);
|
||||||
return joinFaList(indexes.map((index) => PERSIAN_WEEKDAYS[index]).filter(Boolean));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatClassStartDate = (value) => {
|
const formatClassStartDate = (value) => {
|
||||||
@@ -69,17 +99,24 @@ const buildClassScheduleContext = (classDoc, sessions = [], currentSession = nul
|
|||||||
return left - right;
|
return left - right;
|
||||||
});
|
});
|
||||||
const sessionForTime = currentSession || sortedSessions[0] || null;
|
const sessionForTime = currentSession || sortedSessions[0] || null;
|
||||||
|
const storedDays = formatClassDaysFromIndexes(classDoc?.days);
|
||||||
|
const storedTime = formatCourseTime(classDoc?.startTime, classDoc?.endTime);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
classStartDate: formatClassStartDate(classDoc?.startDate || sortedSessions[0]?.day),
|
classStartDate: formatClassStartDate(classDoc?.startDate || sortedSessions[0]?.day),
|
||||||
classDays: formatClassDays(sortedSessions),
|
classDays: storedDays || formatClassDays(sortedSessions),
|
||||||
courseTime: formatCourseTime(sessionForTime?.startTime, sessionForTime?.endTime)
|
courseTime: storedTime || formatCourseTime(sessionForTime?.startTime, sessionForTime?.endTime)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
IRAN_WEEK_ORDER,
|
||||||
|
PERSIAN_WEEKDAYS,
|
||||||
formatCourseTime,
|
formatCourseTime,
|
||||||
formatClassDays,
|
formatClassDays,
|
||||||
|
formatClassDaysFromIndexes,
|
||||||
formatClassStartDate,
|
formatClassStartDate,
|
||||||
|
normalizeWeekdays,
|
||||||
|
normalizeClockTime,
|
||||||
buildClassScheduleContext
|
buildClassScheduleContext
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const {
|
|||||||
formatCourseTime,
|
formatCourseTime,
|
||||||
formatClassDays,
|
formatClassDays,
|
||||||
formatClassStartDate,
|
formatClassStartDate,
|
||||||
|
formatClassDaysFromIndexes,
|
||||||
|
normalizeWeekdays,
|
||||||
|
normalizeClockTime,
|
||||||
buildClassScheduleContext
|
buildClassScheduleContext
|
||||||
} = require('./classSchedule');
|
} = require('./classSchedule');
|
||||||
|
|
||||||
@@ -56,4 +59,40 @@ describe('class schedule SMS helpers', () => {
|
|||||||
|
|
||||||
assert.equal(context.courseTime, 'از 10 تا 12');
|
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]), 'شنبه و دوشنبه');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user