diff --git a/components/classes/classService.js b/components/classes/classService.js index eb194f4..1ba7c2e 100644 --- a/components/classes/classService.js +++ b/components/classes/classService.js @@ -3,9 +3,11 @@ const Class = require('./classModel'); const User = require('../users/userModel'); +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 logger = require('../../utils/logger'); const getAll = async (query) => { @@ -77,12 +79,20 @@ const registerUsers = async (classId, userIds) => { await cls.save(); const classLabel = cls.name || cls.course?.title || 'کلاس'; + const sessions = await Session.find({ class: classId }) + .select('day startTime endTime') + .sort({ day: 1 }) + .lean(); + const schedule = buildClassScheduleContext(cls, sessions); const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber').lean(); await Promise.all( users.map(async (user) => { if (!user.phoneNumber) return; try { - await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id); + await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id, { + courseName: cls.course?.title || classLabel, + ...schedule + }); } catch (err) { logger.error(`[registerUsers] SMS failed for ${user.phoneNumber}: ${err.message}`); } diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index 30e6e17..a81569a 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -9,7 +9,9 @@ const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagin const User = require('../users/userModel'); const Course = require('../courses/courseModel'); const Class = require('../classes/classModel'); +const Session = require('../sessions/sessionModel'); const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages'); +const { buildClassScheduleContext } = require('../../utils/classSchedule'); const logger = require('../../utils/logger'); const getAllPayments = async (query) => { @@ -79,22 +81,34 @@ const createPayment = async (body, actorId = null) => { const user = await User.findById(payment.user).select('name phoneNumber').lean(); if (user?.phoneNumber) { let courseName = ''; + let schedule = { classStartDate: '', classDays: '', courseTime: '' }; if (payment.course) { const course = await Course.findById(payment.course).select('title').lean(); if (course?.title) courseName = course.title; - } else if (payment.classes && payment.classes.length > 0) { + } + if (payment.classes && payment.classes.length > 0) { const cls = await Class.findById(payment.classes[0]).populate('course', 'title').lean(); - if (cls?.course?.title) { - courseName = cls.course.title; - } else if (cls?.name) { - courseName = cls.name; + if (!courseName) { + if (cls?.course?.title) { + courseName = cls.course.title; + } else if (cls?.name) { + courseName = cls.name; + } + } + if (cls) { + const sessions = await Session.find({ class: cls._id }) + .select('day startTime endTime') + .sort({ day: 1 }) + .lean(); + schedule = buildClassScheduleContext(cls, sessions); } } await sendInvoiceCreatedSms(user.phoneNumber, { fullName: user.name || '', amount: payment.amount, - course: courseName || '-' + course: courseName || '-', + ...schedule }, user._id); } } catch (err) { diff --git a/components/settings/smsTemplates.js b/components/settings/smsTemplates.js index a40fc81..b1f4267 100644 --- a/components/settings/smsTemplates.js +++ b/components/settings/smsTemplates.js @@ -20,7 +20,10 @@ const SMS_TEMPLATE_DEFS = [ { key: 'className', label: 'نام کلاس', defaultName: 'className' }, { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, + { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, + { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, + { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' } ] }, { @@ -33,7 +36,10 @@ const SMS_TEMPLATE_DEFS = [ { key: 'time', label: 'ساعت', defaultName: 'time' }, { key: 'place', label: 'مکان', defaultName: 'place' }, { key: 'fullName', label: 'نام کاربر', defaultName: 'fullName' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, + { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, + { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, + { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' } ] }, { @@ -44,7 +50,10 @@ const SMS_TEMPLATE_DEFS = [ { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, { key: 'amount', label: 'مبلغ', defaultName: 'PAYMENT_PRICE' }, { key: 'course', label: 'نام دوره', defaultName: 'COURSE' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' } + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' }, + { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, + { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, + { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' } ] } ]; diff --git a/components/settings/smsTemplates.test.js b/components/settings/smsTemplates.test.js index 880ee9d..421487e 100644 --- a/components/settings/smsTemplates.test.js +++ b/components/settings/smsTemplates.test.js @@ -58,4 +58,15 @@ describe('SMS template variables', () => { assert.deepEqual(merged.variables.map((item) => item.slot), ['amount', 'fullName', 'course']); assert.deepEqual(merged.variables.map((item) => item.name), ['PAYMENT_PRICE', 'FULLNAME', 'COURSE']); }); + + it('exposes class start date, class days, and course time slots', () => { + const expected = ['classStartDate', 'classDays', 'courseTime']; + for (const key of ['classRegistered', 'classReminder', 'invoiceCreated']) { + const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key); + const slots = (def?.slots || []).map((slot) => slot.key); + for (const slot of expected) { + assert.ok(slots.includes(slot), `${key} is missing slot ${slot}`); + } + } + }); }); diff --git a/jobs/classReminderJob.js b/jobs/classReminderJob.js index d366085..eec103e 100644 --- a/jobs/classReminderJob.js +++ b/jobs/classReminderJob.js @@ -5,6 +5,7 @@ const cron = require('node-cron'); const Session = require('../components/sessions/sessionModel'); const User = require('../components/users/userModel'); const { sendClassReminderSms } = require('../utils/senders/smsMessages'); +const { buildClassScheduleContext } = require('../utils/classSchedule'); const logger = require('../utils/logger'); const parseStartDateTime = (session) => { @@ -36,7 +37,7 @@ const runClassReminderJob = async () => { reminderSentAt: null, day: { $gte: dayFrom, $lte: dayTo }, }) - .populate({ path: 'class', select: 'name students' }) + .populate({ path: 'class', select: 'name students startDate' }) .populate({ path: 'course', select: 'title' }) .lean(); @@ -54,6 +55,10 @@ const runClassReminderJob = async () => { const classLabel = classDoc?.name || session.course?.title || 'کلاس'; 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() + : []; + const schedule = buildClassScheduleContext(classDoc, classSessions, session); const users = await User.find({ _id: { $in: studentIds } }).select('phoneNumber').lean(); await Promise.all( @@ -65,7 +70,11 @@ const runClassReminderJob = async () => { classLabel, timeLabel, placeLabel, - user._id + user._id, + { + courseName: session.course?.title || classLabel, + ...schedule + } ); } catch (err) { logger.error(`[ClassReminderJob] SMS failed for ${user.phoneNumber}: ${err.message}`); diff --git a/package.json b/package.json index 277c503..0c9b6b7 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" + "test": "node --test components/settings/smsTemplates.test.js utils/classSchedule.test.js" }, "keywords": [ "express", diff --git a/utils/classSchedule.js b/utils/classSchedule.js new file mode 100644 index 0000000..71dfb9d --- /dev/null +++ b/utils/classSchedule.js @@ -0,0 +1,85 @@ +'use strict'; + +const PERSIAN_WEEKDAYS = ['یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه']; +const IRAN_WEEK_ORDER = [6, 0, 1, 2, 3, 4, 5]; + +const toDate = (value) => { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const formatClockPart = (time) => { + const raw = String(time || '').trim(); + if (!raw) return ''; + const [hoursRaw, minutesRaw] = raw.split(':'); + const hours = String(parseInt(hoursRaw, 10)); + if (hours === 'NaN') return ''; + if (!minutesRaw || minutesRaw === '00') return hours; + return `${hours}:${minutesRaw}`; +}; + +const formatCourseTime = (startTime, endTime) => { + const start = formatClockPart(startTime); + const end = formatClockPart(endTime); + if (!start) return ''; + if (!end) return start; + return `از ${start} تا ${end}`; +}; + +const weekdayIndex = (value) => { + const date = toDate(value); + return date ? date.getDay() : null; +}; + +const joinFaList = (items) => { + if (!items.length) return ''; + if (items.length === 1) return items[0]; + if (items.length === 2) return `${items[0]} و ${items[1]}`; + return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`; +}; + +const formatClassDays = (sessions = []) => { + const indexes = []; + const seen = new Set(); + for (const session of sessions) { + const dayIndex = weekdayIndex(session?.day); + if (dayIndex == null || seen.has(dayIndex)) continue; + 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)); +}; + +const formatClassStartDate = (value) => { + const date = toDate(value); + if (!date) return ''; + try { + return date.toLocaleDateString('fa-IR'); + } catch { + return date.toISOString().slice(0, 10); + } +}; + +const buildClassScheduleContext = (classDoc, sessions = [], currentSession = null) => { + const sortedSessions = [...sessions].sort((a, b) => { + const left = toDate(a?.day)?.getTime() || 0; + const right = toDate(b?.day)?.getTime() || 0; + return left - right; + }); + const sessionForTime = currentSession || sortedSessions[0] || null; + + return { + classStartDate: formatClassStartDate(classDoc?.startDate || sortedSessions[0]?.day), + classDays: formatClassDays(sortedSessions), + courseTime: formatCourseTime(sessionForTime?.startTime, sessionForTime?.endTime) + }; +}; + +module.exports = { + formatCourseTime, + formatClassDays, + formatClassStartDate, + buildClassScheduleContext +}; diff --git a/utils/classSchedule.test.js b/utils/classSchedule.test.js new file mode 100644 index 0000000..9dd4475 --- /dev/null +++ b/utils/classSchedule.test.js @@ -0,0 +1,59 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + formatCourseTime, + formatClassDays, + formatClassStartDate, + buildClassScheduleContext +} = require('./classSchedule'); + +describe('class schedule SMS helpers', () => { + it('formats course time as an hour range', () => { + assert.equal(formatCourseTime('19:00', '21:00'), 'از 19 تا 21'); + assert.equal(formatCourseTime('19:30', '21:00'), 'از 19:30 تا 21'); + }); + + it('joins class weekdays in Persian week order', () => { + const atNoon = (year, month, day) => new Date(year, month - 1, day, 12, 0, 0); + const monday = atNoon(2026, 8, 17); + const saturday = atNoon(2026, 8, 15); + const wednesday = atNoon(2026, 8, 19); + + assert.equal( + formatClassDays([ + { day: monday }, + { day: saturday }, + { day: wednesday }, + { day: saturday } + ]), + 'شنبه، دوشنبه و چهارشنبه' + ); + }); + + it('builds start date, days, and course time from class and sessions', () => { + const atNoon = (year, month, day) => new Date(year, month - 1, day, 12, 0, 0); + const context = buildClassScheduleContext( + { startDate: atNoon(2026, 8, 15) }, + [ + { day: atNoon(2026, 8, 15), startTime: '19:00', endTime: '21:00' }, + { day: atNoon(2026, 8, 17), startTime: '19:00', endTime: '21:00' } + ] + ); + + assert.ok(context.classStartDate); + assert.equal(context.classDays, 'شنبه و دوشنبه'); + assert.equal(context.courseTime, 'از 19 تا 21'); + }); + + it('uses the current session for course time when provided', () => { + const context = buildClassScheduleContext( + { startDate: new Date('2026-08-15T00:00:00Z') }, + [{ day: new Date('2026-08-15T00:00:00Z'), startTime: '19:00', endTime: '21:00' }], + { startTime: '10:00', endTime: '12:00' } + ); + + assert.equal(context.courseTime, 'از 10 تا 12'); + }); +}); diff --git a/utils/senders/smsMessages.js b/utils/senders/smsMessages.js index f9924eb..0ce7cda 100644 --- a/utils/senders/smsMessages.js +++ b/utils/senders/smsMessages.js @@ -58,6 +58,9 @@ const sendClassRegisteredSms = async (receiver, className, userId = null, extraC fullName: fullName || 'کارآموز', phoneNumber: receiver, mobile: receiver, + classStartDate: extraContext.classStartDate || '', + classDays: extraContext.classDays || '', + courseTime: extraContext.courseTime || '', ...extraContext })) }); @@ -85,6 +88,9 @@ const sendClassReminderSms = async (receiver, className, time, place = '', userI fullName: fullName || 'کارآموز', phoneNumber: receiver, mobile: receiver, + classStartDate: extraContext.classStartDate || '', + classDays: extraContext.classDays || '', + courseTime: extraContext.courseTime || extraContext.time || time || '', ...extraContext })) }); @@ -97,11 +103,17 @@ const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => { let fullName = ''; let amount = ''; let course = ''; + let classStartDate = ''; + let classDays = ''; + let courseTime = ''; if (typeof payload === 'object' && payload !== null) { fullName = payload.fullName || ''; amount = payload.amount != null ? String(payload.amount) : ''; course = payload.course || ''; + classStartDate = payload.classStartDate || ''; + classDays = payload.classDays || ''; + courseTime = payload.courseTime || ''; } else { amount = String(payload || ''); } @@ -121,7 +133,10 @@ const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => { amount: amountLabel, course: courseLabel, phoneNumber: receiver, - mobile: receiver + mobile: receiver, + classStartDate, + classDays, + courseTime })) }); };