From 282dd424225c2cd51d41d7723fc31c64038152d8 Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Mon, 24 Aug 2026 19:31:07 +0330 Subject: [PATCH] feat: add employee timing system, notification templates, and professor class plan sms --- app.js | 2 + components/classes/classController.js | 5 + components/classes/classRoutes.js | 1 + components/classes/classService.js | 71 ++- .../employeeTimings/employeeTiming.test.js | 61 +++ .../employeeTimingController.js | 36 ++ .../employeeTimings/employeeTimingModel.js | 120 +++++ .../employeeTimings/employeeTimingRoutes.js | 21 + .../employeeTimings/employeeTimingService.js | 327 ++++++++++++ components/settings/notificationActions.js | 9 + components/settings/settingService.js | 10 +- components/settings/smsTemplateRender.test.js | 63 +++ components/settings/smsTemplates.js | 505 ++++++++++++------ constants/permissions.js | 8 +- package.json | 2 +- seed.js | 6 +- utils/jalaliDate.js | 34 ++ utils/senders/smsMessages.js | 46 +- 18 files changed, 1152 insertions(+), 175 deletions(-) create mode 100644 components/employeeTimings/employeeTiming.test.js create mode 100644 components/employeeTimings/employeeTimingController.js create mode 100644 components/employeeTimings/employeeTimingModel.js create mode 100644 components/employeeTimings/employeeTimingRoutes.js create mode 100644 components/employeeTimings/employeeTimingService.js create mode 100644 components/settings/smsTemplateRender.test.js diff --git a/app.js b/app.js index 88d5555..73da8ea 100644 --- a/app.js +++ b/app.js @@ -106,6 +106,8 @@ const pendingStudentRoutes = require('./components/pendingStudents/pendingStuden const waitlistRoutes = require('./components/waitlist/waitlistRoutes'); app.use('/api/pending-students', pendingStudentRoutes); app.use('/api/waitlist', waitlistRoutes); +const employeeTimingRoutes = require('./components/employeeTimings/employeeTimingRoutes'); +app.use('/api/employee-timings', employeeTimingRoutes); app.use('/api/seed', seedRoutes); app.use('/api/data-import', dataImportRoutes); app.use('/api/settings', settingRoutes); diff --git a/components/classes/classController.js b/components/classes/classController.js index 04340fd..c273d97 100644 --- a/components/classes/classController.js +++ b/components/classes/classController.js @@ -59,3 +59,8 @@ exports.getPublicOne = catchAsync(async (req, res) => { const cls = await classService.getPublicOne(req.params.id); return successResponse(res, 200, 'Class retrieved successfully', cls); }); + +exports.sendClassPlanToProfessor = catchAsync(async (req, res) => { + const result = await classService.sendClassPlanToProfessor(req.params.id); + return successResponse(res, 200, 'برنامه کلاس با موفقیت برای استاد ارسال شد.', result); +}); diff --git a/components/classes/classRoutes.js b/components/classes/classRoutes.js index 294fffe..d9dc747 100644 --- a/components/classes/classRoutes.js +++ b/components/classes/classRoutes.js @@ -27,5 +27,6 @@ router.delete('/admin/delete/:id', perm.requires([PERMISSIONS.CLASSES_DELETE, PE router.post('/admin/restore/:id', perm.requires([PERMISSIONS.CLASSES_DELETE, PERMISSIONS.CLASSES_UPDATE]), 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); +router.post('/admin/:id/send-plan-professor', perm.requires(PERMISSIONS.CLASSES_UPDATE), classController.sendClassPlanToProfessor); module.exports = router; diff --git a/components/classes/classService.js b/components/classes/classService.js index 1dbee77..4e79db9 100644 --- a/components/classes/classService.js +++ b/components/classes/classService.js @@ -8,8 +8,9 @@ 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'); -const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime, calculateClassEndDate } = require('../../utils/classSchedule'); +const { sendClassRegisteredSms, sendClassPlanProfessorSms } = require('../../utils/senders/smsMessages'); +const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime, calculateClassEndDate, formatClassDaysFromIndexes } = require('../../utils/classSchedule'); +const { formatJalaliDate } = require('../../utils/jalaliDate'); const { resolveNotifyFlags } = require('../../utils/notifyResolver'); const { notifyAction } = require('../../utils/actionNotify'); const logger = require('../../utils/logger'); @@ -294,4 +295,68 @@ const getPublicOne = async (id) => { return enrichClassForDisplay(cls); }; -module.exports = { getAll, getOne, create, update, remove, restore, registerUsers, removeUser, getMyClasses, getPublicClasses, getPublicOne }; +const sendClassPlanToProfessor = async (classId) => { + const classDoc = await Class.findById(classId) + .populate('course', 'title') + .populate('professor', 'name surname phoneNumber'); + + if (!classDoc) { + throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد.'); + } + + if (!classDoc.professor) { + throw new AppError('VALIDATION_FAILED', { field: 'professor' }, 'برای این کلاس هیچ استادی تعیین نشده است.'); + } + + const professor = classDoc.professor; + if (!professor.phoneNumber) { + throw new AppError('VALIDATION_FAILED', { field: 'phoneNumber' }, 'شماره همراه استاد ثبت نشده است.'); + } + + const profName = `${professor.name || ''} ${professor.surname || ''}`.trim() || 'استاد'; + const className = classDoc.name || classDoc.course?.title || 'کلاس'; + const classDays = formatClassDaysFromIndexes(classDoc.days) || 'طبق هماهنگی'; + const classTimes = (classDoc.startTime && classDoc.endTime) + ? `${classDoc.startTime} الی ${classDoc.endTime}` + : (classDoc.startTime || classDoc.endTime || 'طبق هماهنگی'); + + const classStartDate = classDoc.startDate ? formatJalaliDate(classDoc.startDate) : ''; + const classEndDate = classDoc.endDate ? formatJalaliDate(classDoc.endDate) : ''; + + const slotValues = { + professorName: profName, + className, + classDays, + classTimes, + classStartDate, + classEndDate, + phoneNumber: professor.phoneNumber + }; + + const result = await sendClassPlanProfessorSms(professor.phoneNumber, slotValues, professor._id); + + return { + success: true, + result, + recipient: { + name: profName, + phoneNumber: professor.phoneNumber + }, + slotValues + }; +}; + +module.exports = { + getAll, + getOne, + create, + update, + remove, + restore, + registerUsers, + removeUser, + getMyClasses, + getPublicClasses, + getPublicOne, + sendClassPlanToProfessor +}; diff --git a/components/employeeTimings/employeeTiming.test.js b/components/employeeTimings/employeeTiming.test.js new file mode 100644 index 0000000..af71dcd --- /dev/null +++ b/components/employeeTimings/employeeTiming.test.js @@ -0,0 +1,61 @@ +// /components/employeeTimings/employeeTiming.test.js +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + computeStatus, + computeDurationMinutes, + computeDurationFormatted +} = require('./employeeTimingService'); + +describe('EmployeeTiming status and warning computations', () => { + it('returns "complete" when both entry and exit times are present', () => { + assert.equal(computeStatus('08:30', '17:00'), 'complete'); + assert.equal(computeStatus('09:00', '14:30'), 'complete'); + }); + + it('returns "missing_exit" when entry is present but exit is missing', () => { + assert.equal(computeStatus('08:30', ''), 'missing_exit'); + assert.equal(computeStatus('08:30', null), 'missing_exit'); + assert.equal(computeStatus('08:30', undefined), 'missing_exit'); + assert.equal(computeStatus('08:30', ' '), 'missing_exit'); + }); + + it('returns "missing_entry" when exit is present but entry is missing', () => { + assert.equal(computeStatus('', '17:00'), 'missing_entry'); + assert.equal(computeStatus(null, '17:00'), 'missing_entry'); + assert.equal(computeStatus(undefined, '17:00'), 'missing_entry'); + assert.equal(computeStatus(' ', '17:00'), 'missing_entry'); + }); + + it('returns "incomplete" when neither entry nor exit is present', () => { + assert.equal(computeStatus('', ''), 'incomplete'); + assert.equal(computeStatus(null, null), 'incomplete'); + assert.equal(computeStatus(undefined, undefined), 'incomplete'); + }); +}); + +describe('EmployeeTiming duration computations', () => { + it('calculates duration in minutes correctly for standard day hours', () => { + assert.equal(computeDurationMinutes('08:00', '16:30'), 510); + assert.equal(computeDurationMinutes('09:15', '10:45'), 90); + }); + + it('handles shift crossing midnight', () => { + assert.equal(computeDurationMinutes('22:00', '02:00'), 240); + }); + + it('returns null for missing times', () => { + assert.equal(computeDurationMinutes('', '17:00'), null); + assert.equal(computeDurationMinutes('08:00', ''), null); + assert.equal(computeDurationMinutes(null, null), null); + }); + + it('formats duration in Persian string', () => { + assert.equal(computeDurationFormatted(510), '8 ساعت و 30 دقیقه'); + assert.equal(computeDurationFormatted(120), '2 ساعت'); + assert.equal(computeDurationFormatted(45), '45 دقیقه'); + assert.equal(computeDurationFormatted(null), '—'); + }); +}); diff --git a/components/employeeTimings/employeeTimingController.js b/components/employeeTimings/employeeTimingController.js new file mode 100644 index 0000000..b522594 --- /dev/null +++ b/components/employeeTimings/employeeTimingController.js @@ -0,0 +1,36 @@ +// /components/employeeTimings/employeeTimingController.js +'use strict'; + +const catchAsync = require('../../utils/catchAsync'); +const employeeTimingService = require('./employeeTimingService'); +const { successResponse } = require('../../utils/apiResponse'); + +exports.getAll = catchAsync(async (req, res) => { + const result = await employeeTimingService.getAll(req.query); + return successResponse(res, 200, 'Employee timing records retrieved', result.items, result.meta); +}); + +exports.getSummary = catchAsync(async (req, res) => { + const summary = await employeeTimingService.getSummary(req.query); + return successResponse(res, 200, 'Employee timing summary retrieved', summary); +}); + +exports.getOne = catchAsync(async (req, res) => { + const record = await employeeTimingService.getOne(req.params.id); + return successResponse(res, 200, 'Employee timing record retrieved', record); +}); + +exports.create = catchAsync(async (req, res) => { + const record = await employeeTimingService.create(req.body, req.user?._id); + return successResponse(res, 201, 'Employee timing record created', record); +}); + +exports.update = catchAsync(async (req, res) => { + const record = await employeeTimingService.update(req.params.id, req.body, req.user?._id); + return successResponse(res, 200, 'Employee timing record updated', record); +}); + +exports.delete = catchAsync(async (req, res) => { + const result = await employeeTimingService.remove(req.params.id); + return successResponse(res, 200, 'Employee timing record deleted', result); +}); diff --git a/components/employeeTimings/employeeTimingModel.js b/components/employeeTimings/employeeTimingModel.js new file mode 100644 index 0000000..c15c057 --- /dev/null +++ b/components/employeeTimings/employeeTimingModel.js @@ -0,0 +1,120 @@ +// /components/employeeTimings/employeeTimingModel.js +'use strict'; + +const mongoose = require('mongoose'); + +const employeeTimingSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + date: { + type: Date, + required: true, + default: Date.now, + index: true + }, + entryTime: { + type: String, + trim: true, + default: '' + }, + exitTime: { + type: String, + trim: true, + default: '' + }, + note: { + type: String, + trim: true, + default: '' + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + updatedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +/** + * Status virtual property: + * - 'complete': both entryTime and exitTime are provided + * - 'missing_exit': entryTime provided, but exitTime missing (Warning) + * - 'missing_entry': exitTime provided, but entryTime missing (Warning) + * - 'incomplete': both entryTime and exitTime are missing (Warning) + */ +employeeTimingSchema.virtual('status').get(function () { + const hasEntry = Boolean(this.entryTime && String(this.entryTime).trim()); + const hasExit = Boolean(this.exitTime && String(this.exitTime).trim()); + + if (hasEntry && hasExit) return 'complete'; + if (hasEntry && !hasExit) return 'missing_exit'; + if (!hasEntry && hasExit) return 'missing_entry'; + return 'incomplete'; +}); + +/** + * Warning flag virtual property: + * True if record is incomplete (missing entry, missing exit, or both) + */ +employeeTimingSchema.virtual('hasWarning').get(function () { + const hasEntry = Boolean(this.entryTime && String(this.entryTime).trim()); + const hasExit = Boolean(this.exitTime && String(this.exitTime).trim()); + return !(hasEntry && hasExit); +}); + +/** + * Warning message helper + */ +employeeTimingSchema.virtual('warningMessage').get(function () { + const hasEntry = Boolean(this.entryTime && String(this.entryTime).trim()); + const hasExit = Boolean(this.exitTime && String(this.exitTime).trim()); + + if (hasEntry && !hasExit) return 'ورود ثبت شده ولی خروج ثبت نشده است'; + if (!hasEntry && hasExit) return 'خروج ثبت شده ولی ورود ثبت نشده است'; + if (!hasEntry && !hasExit) return 'زمان ورود و خروج هیچ‌کدام ثبت نشده است'; + return null; +}); + +/** + * Duration in minutes calculation (if both entry and exit times are valid HH:mm) + */ +employeeTimingSchema.virtual('durationMinutes').get(function () { + if (!this.entryTime || !this.exitTime) return null; + const [inH, inM] = String(this.entryTime).split(':').map(Number); + const [outH, outM] = String(this.exitTime).split(':').map(Number); + if (Number.isNaN(inH) || Number.isNaN(inM) || Number.isNaN(outH) || Number.isNaN(outM)) { + return null; + } + const startMinutes = inH * 60 + inM; + let endMinutes = outH * 60 + outM; + if (endMinutes < startMinutes) { + // Crosses midnight + endMinutes += 24 * 60; + } + return endMinutes - startMinutes; +}); + +/** + * Formatted duration in Persian/hours + */ +employeeTimingSchema.virtual('durationFormatted').get(function () { + const minutes = this.durationMinutes; + if (minutes === null || minutes === undefined) return '—'; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m} دقیقه`; + if (m === 0) return `${h} ساعت`; + return `${h} ساعت و ${m} دقیقه`; +}); + +module.exports = mongoose.model('EmployeeTiming', employeeTimingSchema); diff --git a/components/employeeTimings/employeeTimingRoutes.js b/components/employeeTimings/employeeTimingRoutes.js new file mode 100644 index 0000000..0db32aa --- /dev/null +++ b/components/employeeTimings/employeeTimingRoutes.js @@ -0,0 +1,21 @@ +// /components/employeeTimings/employeeTimingRoutes.js +'use strict'; + +const express = require('express'); +const employeeTimingController = require('./employeeTimingController'); +const authMiddleware = require('../../middlewares/authMiddleware'); +const perm = require('../../middlewares/permissionMiddleware'); +const { PERMISSIONS } = require('../../constants/permissions'); + +const router = express.Router(); + +router.use(authMiddleware); + +router.get('/admin/get-all', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_READ), employeeTimingController.getAll); +router.get('/admin/summary', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_READ), employeeTimingController.getSummary); +router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_READ), employeeTimingController.getOne); +router.post('/admin/create', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_CREATE), employeeTimingController.create); +router.put('/admin/update/:id', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_UPDATE), employeeTimingController.update); +router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_DELETE), employeeTimingController.delete); + +module.exports = router; diff --git a/components/employeeTimings/employeeTimingService.js b/components/employeeTimings/employeeTimingService.js new file mode 100644 index 0000000..bd80162 --- /dev/null +++ b/components/employeeTimings/employeeTimingService.js @@ -0,0 +1,327 @@ +// /components/employeeTimings/employeeTimingService.js +'use strict'; + +const EmployeeTiming = require('./employeeTimingModel'); +const User = require('../users/userModel'); +const AppError = require('../../utils/AppError'); +const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); + +const normalizeTime = (timeStr) => { + if (timeStr === undefined || timeStr === null) return ''; + const trimmed = String(timeStr).trim(); + if (!trimmed) return ''; + // Check HH:mm format + const match = /^([01]?[0-9]|2[0-3]):([0-5][0-9])$/.exec(trimmed); + if (match) { + const hh = match[1].padStart(2, '0'); + const mm = match[2]; + return `${hh}:${mm}`; + } + return trimmed; +}; + +const normalizeDate = (rawDate) => { + if (!rawDate) return new Date(); + const d = new Date(rawDate); + if (Number.isNaN(d.getTime())) { + throw new AppError('VALIDATION_FAILED', { field: 'date' }, 'تاریخ نامعتبر است'); + } + return d; +}; + +const sanitizeNote = (note) => { + if (!note) return ''; + return String(note).trim().slice(0, 1000); +}; + +const computeStatus = (entryTime, exitTime) => { + const hasEntry = Boolean(entryTime && String(entryTime).trim()); + const hasExit = Boolean(exitTime && String(exitTime).trim()); + if (hasEntry && hasExit) return 'complete'; + if (hasEntry && !hasExit) return 'missing_exit'; + if (!hasEntry && hasExit) return 'missing_entry'; + return 'incomplete'; +}; + +const computeDurationMinutes = (entryTime, exitTime) => { + if (!entryTime || !exitTime) return null; + const [inH, inM] = String(entryTime).split(':').map(Number); + const [outH, outM] = String(exitTime).split(':').map(Number); + if (Number.isNaN(inH) || Number.isNaN(inM) || Number.isNaN(outH) || Number.isNaN(outM)) { + return null; + } + const startMinutes = inH * 60 + inM; + let endMinutes = outH * 60 + outM; + if (endMinutes < startMinutes) { + endMinutes += 24 * 60; + } + return endMinutes - startMinutes; +}; + +const computeDurationFormatted = (minutes) => { + if (minutes === null || minutes === undefined) return '—'; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m} دقیقه`; + if (m === 0) return `${h} ساعت`; + return `${h} ساعت و ${m} دقیقه`; +}; + +const enrichRecord = (record) => { + const obj = record.toObject ? record.toObject({ virtuals: true }) : { ...record }; + const entryTime = obj.entryTime || ''; + const exitTime = obj.exitTime || ''; + const status = computeStatus(entryTime, exitTime); + const hasWarning = status !== 'complete'; + const durationMinutes = computeDurationMinutes(entryTime, exitTime); + const durationFormatted = computeDurationFormatted(durationMinutes); + + let warningMessage = null; + if (status === 'missing_exit') warningMessage = 'ورود ثبت شده ولی خروج ثبت نشده است'; + else if (status === 'missing_entry') warningMessage = 'خروج ثبت شده ولی ورود ثبت نشده است'; + else if (status === 'incomplete') warningMessage = 'زمان ورود و خروج هیچ‌کدام ثبت نشده است'; + + return { + ...obj, + status, + hasWarning, + warningMessage, + durationMinutes, + durationFormatted + }; +}; + +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.user || query.userId) { + filter.user = query.user || query.userId; + } + + // Date range filter + if (query.startDate || query.endDate) { + filter.date = {}; + if (query.startDate) { + const start = new Date(query.startDate); + start.setHours(0, 0, 0, 0); + filter.date.$gte = start; + } + if (query.endDate) { + const end = new Date(query.endDate); + end.setHours(23, 59, 59, 999); + filter.date.$lte = end; + } + } + + // Search by user name / username / nationalIdCode + const searchTerm = getSearchTerm(query); + if (searchTerm) { + const escaped = escapeRegex(searchTerm); + const matchingUsers = await User.find({ + $or: [ + { name: new RegExp(escaped, 'i') }, + { username: new RegExp(escaped, 'i') }, + { nationalIdCode: new RegExp(escaped, 'i') }, + { phoneNumber: new RegExp(escaped, 'i') } + ] + }).select('_id').lean(); + + const userIds = matchingUsers.map(u => u._id); + if (filter.user) { + if (!userIds.some(id => String(id) === String(filter.user))) { + return { + items: [], + meta: calculateMeta(0, page, limit) + }; + } + } else { + filter.user = { $in: userIds }; + } + } + + // Filter by status (complete, incomplete, missing_exit, missing_entry) + if (query.status) { + const s = String(query.status).trim(); + if (s === 'complete') { + filter.entryTime = { $nin: ['', null] }; + filter.exitTime = { $nin: ['', null] }; + } else if (s === 'missing_exit') { + filter.entryTime = { $nin: ['', null] }; + filter.$or = [{ exitTime: '' }, { exitTime: null }, { exitTime: { $exists: false } }]; + } else if (s === 'missing_entry') { + filter.exitTime = { $nin: ['', null] }; + filter.$or = [{ entryTime: '' }, { entryTime: null }, { entryTime: { $exists: false } }]; + } else if (s === 'incomplete') { + filter.$or = [ + { entryTime: '' }, + { entryTime: null }, + { entryTime: { $exists: false } }, + { exitTime: '' }, + { exitTime: null }, + { exitTime: { $exists: false } } + ]; + } + } + + const [records, total] = await Promise.all([ + EmployeeTiming.find(filter) + .populate('user', 'name nationalIdCode phoneNumber username role uniqueCode') + .populate('createdBy', 'name username') + .populate('updatedBy', 'name username') + .sort({ date: -1, createdAt: -1 }) + .skip(skip) + .limit(limit), + EmployeeTiming.countDocuments(filter) + ]); + + return { + items: records.map(enrichRecord), + meta: calculateMeta(total, page, limit) + }; +}; + +const getSummary = async (query = {}) => { + const baseFilter = {}; + if (query.user || query.userId) { + baseFilter.user = query.user || query.userId; + } + if (query.startDate || query.endDate) { + baseFilter.date = {}; + if (query.startDate) { + const start = new Date(query.startDate); + start.setHours(0, 0, 0, 0); + baseFilter.date.$gte = start; + } + if (query.endDate) { + const end = new Date(query.endDate); + end.setHours(23, 59, 59, 999); + baseFilter.date.$lte = end; + } + } + + const [total, complete, missingExit, missingEntry] = await Promise.all([ + EmployeeTiming.countDocuments(baseFilter), + EmployeeTiming.countDocuments({ + ...baseFilter, + entryTime: { $nin: ['', null] }, + exitTime: { $nin: ['', null] } + }), + EmployeeTiming.countDocuments({ + ...baseFilter, + entryTime: { $nin: ['', null] }, + $or: [{ exitTime: '' }, { exitTime: null }, { exitTime: { $exists: false } }] + }), + EmployeeTiming.countDocuments({ + ...baseFilter, + exitTime: { $nin: ['', null] }, + $or: [{ entryTime: '' }, { entryTime: null }, { entryTime: { $exists: false } }] + }) + ]); + + const incomplete = total - complete; + + return { + total, + complete, + incomplete, + missingExit, + missingEntry + }; +}; + +const getOne = async (id) => { + const record = await EmployeeTiming.findById(id) + .populate('user', 'name nationalIdCode phoneNumber username role uniqueCode') + .populate('createdBy', 'name username') + .populate('updatedBy', 'name username'); + + if (!record) { + throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد'); + } + + return enrichRecord(record); +}; + +const create = async (body, authorId) => { + if (!body.user && !body.userId) { + throw new AppError('VALIDATION_FAILED', { field: 'user' }, 'انتخاب کارمند / کاربر الزامی است'); + } + + const userId = body.user || body.userId; + const userExists = await User.findById(userId).select('_id name').lean(); + if (!userExists) { + throw new AppError('NOT_FOUND', { resource: 'User' }, 'کاربر انتخاب‌شده یافت نشد'); + } + + const record = await EmployeeTiming.create({ + user: userId, + date: normalizeDate(body.date), + entryTime: normalizeTime(body.entryTime), + exitTime: normalizeTime(body.exitTime), + note: sanitizeNote(body.note), + createdBy: authorId || undefined, + updatedBy: authorId || undefined + }); + + return getOne(record._id); +}; + +const update = async (id, body, authorId) => { + const record = await EmployeeTiming.findById(id); + if (!record) { + throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد'); + } + + if (body.user !== undefined || body.userId !== undefined) { + const targetUserId = body.user || body.userId; + const userExists = await User.findById(targetUserId).select('_id').lean(); + if (!userExists) { + throw new AppError('NOT_FOUND', { resource: 'User' }, 'کاربر انتخاب‌شده یافت نشد'); + } + record.user = targetUserId; + } + + if (body.date !== undefined) { + record.date = normalizeDate(body.date); + } + if (body.entryTime !== undefined) { + record.entryTime = normalizeTime(body.entryTime); + } + if (body.exitTime !== undefined) { + record.exitTime = normalizeTime(body.exitTime); + } + if (body.note !== undefined) { + record.note = sanitizeNote(body.note); + } + + if (authorId) { + record.updatedBy = authorId; + } + + await record.save(); + return getOne(record._id); +}; + +const remove = async (id) => { + const record = await EmployeeTiming.findByIdAndDelete(id); + if (!record) { + throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد'); + } + return { success: true }; +}; + +module.exports = { + getAll, + getSummary, + getOne, + create, + update, + remove, + computeStatus, + computeDurationMinutes, + computeDurationFormatted +}; diff --git a/components/settings/notificationActions.js b/components/settings/notificationActions.js index f4832c9..5d1b94d 100644 --- a/components/settings/notificationActions.js +++ b/components/settings/notificationActions.js @@ -65,6 +65,15 @@ const NOTIFICATION_ACTION_DEFS = [ smsTemplateKey: 'sessionHolding', defaultChannels: { sms: true, email: false, bot: false } }, + { + key: 'classPlanProfessor', + label: 'ارسال برنامه کلاس به استاد', + description: 'ارسال جزئیات و زمان‌بندی برگزاری کلاس به استاد', + category: 'classes', + relatedEvent: 'class.plan_to_professor', + smsTemplateKey: 'classPlanProfessor', + defaultChannels: { sms: true, email: false, bot: false } + }, { key: 'invoiceCreated', label: 'ایجاد صورتحساب', diff --git a/components/settings/settingService.js b/components/settings/settingService.js index a47af4c..b0f8a9f 100644 --- a/components/settings/settingService.js +++ b/components/settings/settingService.js @@ -61,6 +61,8 @@ const toPublicTemplates = (storedMap, notificationMap = {}) => { return { key: def.key, label: def.label, + category: entry.category || def.category || 'اطلاع‌رسانی', + text: entry.text || def.defaultText || '', enabled: isEnabled, templateId: resolveTemplateId(entry, def), availableSlots: (def.slots || []).map((slot) => ({ @@ -115,7 +117,7 @@ const getSettings = async () => { const getSmsTemplate = async (key) => { const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key); if (!def) { - return { templateId: '', variables: [], enabled: true }; + return { templateId: '', variables: [], enabled: true, text: '', category: 'اطلاع‌رسانی' }; } const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); const entry = normalizeStoredEntry(readStoredMap(doc)[key], def); @@ -128,6 +130,8 @@ const getSmsTemplate = async (key) => { return { enabled: isEnabled, templateId: resolveTemplateId(entry, def), + category: entry.category || def.category || 'اطلاع‌رسانی', + text: entry.text || def.defaultText || '', variables: resolveVariablesList(def, entry.variables) }; }; @@ -140,12 +144,14 @@ const getSmsTemplateId = async (key) => { const parseIncomingEntry = (raw) => { if (raw == null) return null; if (typeof raw === 'string' || typeof raw === 'number') { - return { templateId: raw, variables: undefined, enabled: undefined }; + return { templateId: raw, variables: undefined, enabled: undefined, text: undefined, category: undefined }; } if (typeof raw !== 'object') return null; return { enabled: raw.enabled !== undefined ? Boolean(raw.enabled) : undefined, templateId: raw.templateId, + category: raw.category, + text: raw.text, variables: raw.variables }; }; diff --git a/components/settings/smsTemplateRender.test.js b/components/settings/smsTemplateRender.test.js new file mode 100644 index 0000000..44d1623 --- /dev/null +++ b/components/settings/smsTemplateRender.test.js @@ -0,0 +1,63 @@ +// /components/settings/smsTemplateRender.test.js +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { renderSmsText, SMS_TEMPLATE_DEFS } = require('./smsTemplates'); +const { formatJalaliDate } = require('../../utils/jalaliDate'); + +describe('SMS Template Text Rendering', () => { + it('renders class plan to professor template correctly with variables', () => { + const def = SMS_TEMPLATE_DEFS.find((d) => d.key === 'classPlanProfessor'); + assert.ok(def); + + const slotValues = { + professorName: 'علی رضایی', + className: 'برنامه‌نویسی وب', + classDays: 'شنبه و دوشنبه', + classTimes: '۱۶:۰۰ الی ۱۸:۰۰', + classStartDate: '1405/06/01', + classEndDate: '1405/08/15' + }; + + const rendered = renderSmsText(def.defaultText, def.slots.map(s => ({ slot: s.key, name: s.defaultName })), slotValues); + assert.match(rendered, /استاد علی رضایی/); + assert.match(rendered, /برنامه‌نویسی وب/); + assert.match(rendered, /شنبه و دوشنبه/); + assert.match(rendered, /۱۶:۰۰ الی ۱۸:۰۰/); + assert.match(rendered, /1405\/06\/01/); + assert.match(rendered, /1405\/08\/15/); + }); + + it('renders sessionHolding template correctly', () => { + const def = SMS_TEMPLATE_DEFS.find((d) => d.key === 'sessionHolding'); + assert.ok(def); + + const slotValues = { + fullName: 'سارا محمدی', + topic: 'مقدمه‌ای بر پایگاه داده', + className: 'کلاس بک‌اند', + sessionDate: '1405/06/10', + classTime: '17:00' + }; + + const rendered = renderSmsText(def.defaultText, [ + { slot: 'fullName', name: 'FULLNAME' }, + { slot: 'topic', name: 'TOPIC' }, + { slot: 'className', name: 'CLASSNAME' }, + { slot: 'sessionDate', name: 'SESSIONDATE' }, + { slot: 'classTime', name: 'CLASSTIME' } + ], slotValues); + + assert.match(rendered, /سارا محمدی عزیز/); + assert.match(rendered, /مقدمه‌ای بر پایگاه داده/); + assert.match(rendered, /کلاس بک‌اند/); + assert.match(rendered, /1405\/06\/10/); + assert.match(rendered, /17:00/); + }); + + it('formats dates into Jalali correctly', () => { + const formatted = formatJalaliDate('2026-08-24T00:00:00.000Z'); + assert.equal(formatted, '1405/06/02'); + }); +}); diff --git a/components/settings/smsTemplates.js b/components/settings/smsTemplates.js index 22d16a7..f343dec 100644 --- a/components/settings/smsTemplates.js +++ b/components/settings/smsTemplates.js @@ -1,137 +1,17 @@ +// /components/settings/smsTemplates.js 'use strict'; const SMS_TEMPLATE_DEFS = [ - { - key: 'accountCreated', - label: 'ایجاد حساب کاربری', - envKey: 'SMS_TEMPLATE_ACCOUNT_CREATED', - slots: [ - { key: 'username', label: 'نام کاربری', defaultName: 'user' }, - { key: 'password', label: 'رمز عبور', defaultName: 'password' }, - { key: 'fullName', label: 'نام کاربر', defaultName: 'name' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, - { key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' } - ] - }, - { - key: 'classRegistered', - label: 'ثبت‌نام در کلاس', - envKey: 'SMS_TEMPLATE_CLASS_REGISTERED', - slots: [ - { key: 'className', label: 'نام کلاس', defaultName: 'className' }, - { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, - { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, - { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, - { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, - { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }, - { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' } - ] - }, - { - key: 'classReminder', - label: 'یادآوری کلاس', - envKey: 'SMS_TEMPLATE_CLASS_REMINDER', - slots: [ - { key: 'className', label: 'نام کلاس', defaultName: 'className' }, - { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, - { key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' }, - { key: 'time', label: 'ساعت', defaultName: 'time' }, - { key: 'classTime', label: 'ساعت کلاس', defaultName: 'classTime' }, - { key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' }, - { key: 'place', label: 'مکان', defaultName: 'place' }, - { key: 'fullName', label: 'نام کاربر', defaultName: 'fullName' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, - { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, - { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, - { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }, - { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' } - ] - }, - { - key: 'invoiceCreated', - label: 'ایجاد صورتحساب', - envKey: 'SMS_TEMPLATE_INVOICE_CREATED', - slots: [ - { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, - { key: 'amount', label: 'مبلغ', defaultName: 'PAYMENT_PRICE' }, - { key: 'course', label: 'نام دوره', defaultName: 'COURSE' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' }, - { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, - { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, - { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }, - { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' } - ] - }, - { - key: 'passwordReset', - label: 'بازنشانی رمز عبور', - envKey: 'SMS_TEMPLATE_PASSWORD_RESET', - slots: [ - { key: 'username', label: 'نام کاربری', defaultName: 'user' }, - { key: 'password', label: 'رمز عبور', defaultName: 'password' }, - { key: 'fullName', label: 'نام کاربر', defaultName: 'name' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, - { key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' } - ] - }, - { - key: 'paymentStatusChanged', - label: 'تغییر وضعیت پرداخت', - envKey: 'SMS_TEMPLATE_PAYMENT_STATUS_CHANGED', - slots: [ - { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, - { key: 'status', label: 'وضعیت', defaultName: 'status' }, - { key: 'amount', label: 'مبلغ', defaultName: 'amount' }, - { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }, - { key: 'course', label: 'نام دوره', defaultName: 'course' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } - ] - }, - { - key: 'paymentReminder', - label: 'یادآوری سررسید پرداخت', - envKey: 'SMS_TEMPLATE_PAYMENT_REMINDER', - slots: [ - { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, - { key: 'amount', label: 'مبلغ باقی‌مانده', defaultName: 'amount' }, - { key: 'dueDate', label: 'تاریخ سررسید', defaultName: 'dueDate' }, - { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }, - { key: 'course', label: 'نام دوره', defaultName: 'course' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } - ] - }, - { - key: 'transactionRecorded', - label: 'ثبت تراکنش', - envKey: 'SMS_TEMPLATE_TRANSACTION_RECORDED', - slots: [ - { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, - { key: 'amount', label: 'مبلغ', defaultName: 'amount' }, - { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }, - { key: 'transactionCode', label: 'کد تراکنش', defaultName: 'transactionCode' }, - { key: 'receiptNumber', label: 'شماره رسید', defaultName: 'receiptNumber' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } - ] - }, - { - key: 'sessionCancelled', - label: 'لغو جلسه', - envKey: 'SMS_TEMPLATE_SESSION_CANCELLED', - slots: [ - { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, - { key: 'className', label: 'نام کلاس', defaultName: 'className' }, - { key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' }, - { key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' }, - { key: 'reason', label: 'دلیل لغو', defaultName: 'reason' }, - { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } - ] - }, { key: 'sessionHolding', - label: 'برگزاری جلسه طبق برنامه', + label: 'کلاس طبق برنامه', + category: 'اطلاع‌رسانی', + defaultTemplateId: '720661', envKey: 'SMS_TEMPLATE_SESSION_HOLDING', + defaultText: ` #FULLNAME# عزیز، «#TOPIC#» کلاس #CLASSNAME# در تاریخ #SESSIONDATE# و ساعت #CLASSTIME# طبق برنامه برگزار خواهد شد. + +آموزشگاه گام نو +game-no.ir`, slots: [ { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, { key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' }, @@ -144,14 +24,245 @@ const SMS_TEMPLATE_DEFS = [ { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } ] }, + { + key: 'certificateIssued', + label: 'صدور گواهی', + category: 'اطلاع‌رسانی', + defaultTemplateId: '854493', + envKey: 'SMS_TEMPLATE_CERTIFICATE_ISSUED', + defaultText: ` #FULLNAME# عزیز، گواهی «#CERTIFICATETITLE#» شما، برای دوره #COURSENAME# صادر شد. +جهت مشاهده و دانلود گواهی به پروفایل خود در وبسایت آموزشگاه مراجعه کنید. + +آموزشگاه گام نو +game-no.ir`, + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'CERTIFICATETITLE' }, + { key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' }, + { key: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'sessionCancelled', + label: 'لغو کلاس', + category: 'اطلاع‌رسانی', + defaultTemplateId: '174248', + envKey: 'SMS_TEMPLATE_SESSION_CANCELLED', + defaultText: ` #FULLNAME# عزیز، جلسه «#TOPIC#» کلاس #CLASSNAME# در تاریخ #SESSIONDATE# برگزار نخواهد شد. + +آموزشگاه گام نو +game-no.ir`, + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' }, + { key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' }, + { key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' }, + { key: 'reason', label: 'دلیل لغو', defaultName: 'reason' }, + { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'transactionRecorded', + label: 'ثبت تراکنش', + category: 'اطلاع‌رسانی', + defaultTemplateId: '580983', + envKey: 'SMS_TEMPLATE_TRANSACTION_RECORDED', + defaultText: ` #FULLNAME# عزیز، تراکنش #TRANSACTIONCODE# به مبلغ #AMOUNT# تومان ثبت شد. +صورتحساب: #INVOICECODE# — رسید: #RECEIPTNUMBER# + +آموزشگاه گام نو +game-no.ir`, + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'transactionCode', label: 'کد تراکنش', defaultName: 'TRANSACTIONCODE' }, + { key: 'amount', label: 'مبلغ', defaultName: 'AMOUNT' }, + { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' }, + { key: 'receiptNumber', label: 'شماره رسید', defaultName: 'RECEIPTNUMBER' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'paymentReminder', + label: 'یادآوری پرداخت', + category: 'اطلاع‌رسانی', + defaultTemplateId: '357550', + envKey: 'SMS_TEMPLATE_PAYMENT_REMINDER', + defaultText: ` #FULLNAME# عزیز، یادآوری پرداخت: +صورتحساب #INVOICECODE# — مبلغ باقیمانده: #AMOUNT# تومان +سررسید: #DUEDATE# +دوره: #COURSE# + +آموزشگاه گام نو +game-no.ir`, + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' }, + { key: 'amount', label: 'مبلغ باقیمانده', defaultName: 'AMOUNT' }, + { key: 'dueDate', label: 'تاریخ سررسید', defaultName: 'DUEDATE' }, + { key: 'course', label: 'نام دوره', defaultName: 'COURSE' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'paymentStatusChanged', + label: 'وضعیت صورتحساب', + category: 'وضعیت سفارش', + defaultTemplateId: '270174', + envKey: 'SMS_TEMPLATE_PAYMENT_STATUS_CHANGED', + defaultText: ` #FULLNAME# عزیز، وضعیت صورتحساب #INVOICECODE# به «#STATUS#» تغییر کرد. +مبلغ: #AMOUNT# تومان — دوره: #COURSE# + +آموزشگاه گام نو +game-no.ir`, + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' }, + { key: 'status', label: 'وضعیت', defaultName: 'STATUS' }, + { key: 'amount', label: 'مبلغ', defaultName: 'AMOUNT' }, + { key: 'course', label: 'نام دوره', defaultName: 'COURSE' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'accountCreated', + label: 'اطلاعات اکانت', + category: 'اطلاع‌رسانی', + defaultTemplateId: '250973', + envKey: 'SMS_TEMPLATE_ACCOUNT_CREATED', + defaultText: ` کارآموز گرامی، یک حساب کاربری برای شما در وبسایت آموزشگاه گام نو ایجاد شد. +نام کاربری: #USER# +رمز عبور: #PASSWORD# +game-no.ir`, + slots: [ + { key: 'username', label: 'نام کاربری', defaultName: 'USER' }, + { key: 'password', label: 'رمز عبور', defaultName: 'PASSWORD' }, + { key: 'fullName', label: 'نام کاربر', defaultName: 'name' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, + { key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' } + ] + }, + { + key: 'invoiceCreated', + label: 'پرداخت', + category: 'اطلاع‌رسانی', + defaultTemplateId: '580892', + envKey: 'SMS_TEMPLATE_INVOICE_CREATED', + defaultText: ` کارآموز عزیز، #FULLNAME#، یک صورتحساب به مبلغ #PAYMENT_PRICE# تومان، بابت دوره ی #COURSE# برای شما ایجاد شده است. + +آموزشگاه گام نو +game-no.ir`, + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'amount', label: 'مبلغ', defaultName: 'PAYMENT_PRICE' }, + { key: 'course', label: 'نام دوره', defaultName: 'COURSE' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' }, + { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, + { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, + { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }, + { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' } + ] + }, + { + key: 'classRegistered', + label: 'ثبت نام دوره', + category: 'اطلاع‌رسانی', + defaultTemplateId: '910210', + envKey: 'SMS_TEMPLATE_CLASS_REGISTERED', + defaultText: ` کارآموز عزیز، #FULLNAME#، ثبت نام شما در دوره #CLASS# انجام شد. +هر هفته #CLASSDAYS#، #CLASSTIME# + +آموزشگاه گام نو +game-no.ir`, + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'className', label: 'نام کلاس', defaultName: 'CLASS' }, + { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'CLASSDAYS' }, + { key: 'courseTime', label: 'ساعت دوره', defaultName: 'CLASSTIME' }, + { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, + { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, + { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' } + ] + }, + { + key: 'classPlanProfessor', + label: 'برنامه کلاس به استاد', + category: 'اطلاع‌رسانی', + defaultTemplateId: '', + envKey: 'SMS_TEMPLATE_CLASS_PLAN_PROFESSOR', + defaultText: `با سلام و وقت بخیر، استاد #professorName#، +برنامه کلاس #className# شما به شرح زیر می باشد: +#classDays#، #classTimes# +از #classStartDate# الی #classEndDate#`, + slots: [ + { key: 'professorName', label: 'نام استاد', defaultName: 'professorName' }, + { key: 'className', label: 'نام کلاس', defaultName: 'className' }, + { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, + { key: 'classTimes', label: 'ساعت برگزاری', defaultName: 'classTimes' }, + { key: 'classStartDate', label: 'تاریخ شروع', defaultName: 'classStartDate' }, + { key: 'classEndDate', label: 'تاریخ پایان', defaultName: 'classEndDate' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'passwordReset', + label: 'بازنشانی رمز عبور', + category: 'اطلاع‌رسانی', + defaultTemplateId: '250973', + envKey: 'SMS_TEMPLATE_PASSWORD_RESET', + defaultText: ` کارآموز گرامی، رمز عبور حساب کاربری شما در وبسایت آموزشگاه گام نو بازنشانی شد. +نام کاربری: #USER# +رمز عبور: #PASSWORD# +game-no.ir`, + slots: [ + { key: 'username', label: 'نام کاربری', defaultName: 'USER' }, + { key: 'password', label: 'رمز عبور', defaultName: 'PASSWORD' }, + { key: 'fullName', label: 'نام کاربر', defaultName: 'name' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, + { key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' } + ] + }, + { + key: 'classReminder', + label: 'یادآوری کلاس', + category: 'اطلاع‌رسانی', + defaultTemplateId: '', + envKey: 'SMS_TEMPLATE_CLASS_REMINDER', + defaultText: ` #FULLNAME# عزیز، یادآوری کلاس «#CLASSNAME#» در تاریخ #SESSIONDATE# ساعت #CLASSTIME#. +مکان: #PLACE# + +آموزشگاه گام نو +game-no.ir`, + slots: [ + { key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' }, + { key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' }, + { key: 'classTime', label: 'ساعت کلاس', defaultName: 'CLASSTIME' }, + { key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' }, + { key: 'place', label: 'مکان', defaultName: 'PLACE' }, + { key: 'fullName', label: 'نام کاربر', defaultName: 'FULLNAME' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, + { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, + { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, + { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }, + { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' } + ] + }, { key: 'classRequestApproved', label: 'تأیید درخواست تشکیل کلاس', + category: 'ثبت‌نام و درخواست‌ها', + defaultTemplateId: '', envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED', defaultEnabled: false, + defaultText: ` #FULLNAME# عزیز، درخواست شما برای تشکیل کلاس #COURSENAME# تأیید شد. + +آموزشگاه گام نو +game-no.ir`, slots: [ - { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, - { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' }, { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }, { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } ] @@ -159,12 +270,19 @@ const SMS_TEMPLATE_DEFS = [ { key: 'classRequestRejected', label: 'رد درخواست ثبت‌نام', + category: 'ثبت‌نام و درخواست‌ها', + defaultTemplateId: '', envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED', defaultEnabled: false, + defaultText: ` #FULLNAME# عزیز، درخواست ثبت‌نام شما برای دوره #COURSENAME# مورد پذیرش قرار نگرفت. +دلیل: #REASON# + +آموزشگاه گام نو +game-no.ir`, slots: [ - { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, - { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, - { key: 'reason', label: 'دلیل', defaultName: 'reason' }, + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' }, + { key: 'reason', label: 'دلیل', defaultName: 'REASON' }, { key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' }, { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } ] @@ -172,24 +290,18 @@ const SMS_TEMPLATE_DEFS = [ { key: 'pendingRegistration', label: 'دریافت درخواست ثبت‌نام', + category: 'ثبت‌نام و درخواست‌ها', + defaultTemplateId: '', envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION', defaultEnabled: false, + defaultText: ` #FULLNAME# عزیز، درخواست ثبت‌نام شما در کلاس #CLASSNAME# با کد پیگیری #REGISTRATIONCODE# دریافت شد. + +آموزشگاه گام نو +game-no.ir`, slots: [ - { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, - { key: 'className', label: 'نام کلاس', defaultName: 'className' }, - { key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } - ] - }, - { - key: 'certificateIssued', - label: 'صدور گواهینامه', - envKey: 'SMS_TEMPLATE_CERTIFICATE_ISSUED', - slots: [ - { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, - { key: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'certificateTitle' }, - { key: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' }, - { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' }, + { key: 'registrationCode', label: 'کد درخواست', defaultName: 'REGISTRATIONCODE' }, { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } ] } @@ -214,12 +326,15 @@ const sanitizeVariableName = (value) => { if (value == null) return ''; const name = String(value).trim().replace(/^#+|#+$/g, '').trim(); if (!name) return ''; - // Check for dangerous injection or control chars if (/[\r\n\t\0<>"'`]/.test(name)) return null; if (name.length > 100) return null; return name; }; +const escapeRegExp = (string) => { + return String(string).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}; + const defaultVariablesList = (def) => { return (def?.slots || []).map((slot) => ({ slot: slot.key, @@ -229,7 +344,9 @@ const defaultVariablesList = (def) => { const emptyTemplateEntry = (def) => ({ enabled: def ? (def.defaultEnabled !== false) : true, - templateId: '', + templateId: def?.defaultTemplateId || '', + category: def?.category || 'اطلاع‌رسانی', + text: def?.defaultText || '', variables: defaultVariablesList(def) }); @@ -271,10 +388,16 @@ const normalizeVariablesInput = (rawVariables, def) => { const normalizeStoredEntry = (raw, def) => { const defaultEnabled = def ? (def.defaultEnabled !== false) : true; + const defaultText = def?.defaultText || ''; + const defaultTemplateId = def?.defaultTemplateId || ''; + const defaultCategory = def?.category || 'اطلاع‌رسانی'; + if (raw == null || raw === '') { return { enabled: defaultEnabled, - templateId: '', + templateId: defaultTemplateId, + category: defaultCategory, + text: defaultText, variables: def ? defaultVariablesList(def) : [] }; } @@ -282,13 +405,17 @@ const normalizeStoredEntry = (raw, def) => { return { enabled: true, templateId: String(raw), + category: defaultCategory, + text: defaultText, variables: def ? defaultVariablesList(def) : [] }; } if (typeof raw !== 'object') { return { enabled: defaultEnabled, - templateId: '', + templateId: defaultTemplateId, + category: defaultCategory, + text: defaultText, variables: def ? defaultVariablesList(def) : [] }; } @@ -301,9 +428,21 @@ const normalizeStoredEntry = (raw, def) => { ? Boolean(raw.enabled) : defaultEnabled; + const templateId = raw.templateId != null && String(raw.templateId).trim() !== '' + ? String(raw.templateId).trim() + : defaultTemplateId; + + const text = raw.text != null && String(raw.text).trim() !== '' + ? String(raw.text) + : defaultText; + + const category = raw.category || defaultCategory; + return { enabled, - templateId: raw.templateId != null ? String(raw.templateId) : '', + templateId, + category, + text, variables }; }; @@ -315,15 +454,22 @@ const parseIncomingVariables = (rawVariables, def) => { const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => { const storedEntry = normalizeStoredEntry(storedRaw, def); - let templateId = storedEntry.templateId || envFallbackFor(def); + let templateId = storedEntry.templateId || envFallbackFor(def) || def?.defaultTemplateId || ''; let variables = storedEntry.variables; let enabled = storedEntry.enabled; + let text = storedEntry.text || def?.defaultText || ''; + let category = storedEntry.category || def?.category || 'اطلاع‌رسانی'; if (incomingRaw) { if (incomingRaw.enabled !== undefined) { enabled = Boolean(incomingRaw.enabled); } - + if (incomingRaw.text !== undefined) { + text = String(incomingRaw.text || ''); + } + if (incomingRaw.category !== undefined) { + category = String(incomingRaw.category || ''); + } if (incomingRaw.templateId !== undefined) { const sanitized = sanitizeTemplateId(incomingRaw.templateId); if (sanitized === null) { @@ -334,7 +480,6 @@ const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => { } templateId = sanitized; } - if (incomingRaw.variables !== undefined) { const parsed = parseIncomingVariables(incomingRaw.variables, def) || []; for (const item of parsed) { @@ -350,12 +495,14 @@ const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => { variables = parsed; } } else if (!storedEntry.templateId) { - templateId = envFallbackFor(def); + templateId = envFallbackFor(def) || def?.defaultTemplateId || ''; } return { enabled, templateId, + category, + text, variables: resolveVariablesList(def, variables) }; }; @@ -417,6 +564,46 @@ const buildSmsParameters = (variables, valuesBySlot = {}) => { .filter(Boolean); }; +/** + * Renders the exact text of an SMS template by replacing variable placeholders (e.g. #FULLNAME# or #className#) + * with the corresponding values provided in slotValues. + */ +const renderSmsText = (templateText, variables = [], slotValues = {}) => { + if (!templateText || typeof templateText !== 'string') return ''; + let result = templateText; + + // 1. Replace defined template variables (mapped by slot and variable name in sms.ir) + if (Array.isArray(variables)) { + for (const item of variables) { + if (!item || !item.name) continue; + const varName = String(item.name).trim().replace(/^#+|#+$/g, ''); + if (!varName) continue; + const slotKey = item.slot; + let val = slotKey !== undefined && slotValues[slotKey] !== undefined ? slotValues[slotKey] : ''; + if (isPriceSlot(slotKey, varName) && val != null && val !== '') { + val = formatPriceValue(val); + } + const pattern = new RegExp(`#${escapeRegExp(varName)}#`, 'gi'); + result = result.replace(pattern, String(val ?? '')); + } + } + + // 2. Direct slot replacements (e.g., #professorName#, #className#, #amount#, #fullName#) + if (slotValues && typeof slotValues === 'object') { + for (const [slotKey, rawVal] of Object.entries(slotValues)) { + if (!slotKey) continue; + let val = rawVal; + if (isPriceSlot(slotKey, slotKey) && val != null && val !== '') { + val = formatPriceValue(val); + } + const pattern = new RegExp(`#${escapeRegExp(slotKey)}#`, 'gi'); + result = result.replace(pattern, String(val ?? '')); + } + } + + return result; +}; + module.exports = { SMS_TEMPLATE_DEFS, SMS_TEMPLATE_KEYS, @@ -429,5 +616,7 @@ module.exports = { parseIncomingVariables, mergeTemplateEntry, resolveVariablesList, - buildSmsParameters + buildSmsParameters, + renderSmsText, + formatPriceValue }; diff --git a/constants/permissions.js b/constants/permissions.js index 6b5225f..8d537ae 100644 --- a/constants/permissions.js +++ b/constants/permissions.js @@ -105,7 +105,13 @@ const PERMISSIONS = { WAITLIST_CREATE: 'waitlist:create', WAITLIST_READ: 'waitlist:read', WAITLIST_UPDATE: 'waitlist:update', - WAITLIST_DELETE: 'waitlist:delete' + WAITLIST_DELETE: 'waitlist:delete', + + // Employee timing / attendance permissions + EMPLOYEE_TIMINGS_CREATE: 'employee_timings:create', + EMPLOYEE_TIMINGS_READ: 'employee_timings:read', + EMPLOYEE_TIMINGS_UPDATE: 'employee_timings:update', + EMPLOYEE_TIMINGS_DELETE: 'employee_timings:delete' }; const ALL_PERMISSIONS = Object.values(PERMISSIONS); diff --git a/package.json b/package.json index 7230fe3..f97d55e 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/waitlist/waitlistService.test.js components/payments/bulkPayment.test.js 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" + "test": "node --test components/employeeTimings/employeeTiming.test.js components/settings/smsTemplateRender.test.js components/waitlist/waitlistService.test.js components/payments/bulkPayment.test.js 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", diff --git a/seed.js b/seed.js index 5eb0d5f..a3f61e3 100644 --- a/seed.js +++ b/seed.js @@ -65,7 +65,11 @@ const defaultRoles = [ PERMISSIONS.WAITLIST_CREATE, PERMISSIONS.WAITLIST_READ, PERMISSIONS.WAITLIST_UPDATE, - PERMISSIONS.WAITLIST_DELETE + PERMISSIONS.WAITLIST_DELETE, + PERMISSIONS.EMPLOYEE_TIMINGS_CREATE, + PERMISSIONS.EMPLOYEE_TIMINGS_READ, + PERMISSIONS.EMPLOYEE_TIMINGS_UPDATE, + PERMISSIONS.EMPLOYEE_TIMINGS_DELETE ], isSystem: true }, diff --git a/utils/jalaliDate.js b/utils/jalaliDate.js index 8041727..c27ef9e 100644 --- a/utils/jalaliDate.js +++ b/utils/jalaliDate.js @@ -100,6 +100,38 @@ const parseImportDate = (raw) => { return Number.isNaN(date.getTime()) ? null : date; }; +const gregorianToJalali = (gy, gm, gd) => { + const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; + let jy; + if (gy > 1600) { + jy = 979; + gy -= 1600; + } else { + jy = 0; + gy -= 621; + } + const gy2 = gm > 2 ? gy + 1 : gy; + let days = 365 * gy + Math.floor((gy2 + 3) / 4) - Math.floor((gy2 + 99) / 100) + Math.floor((gy2 + 399) / 400) - 80 + gd + g_d_m[gm - 1]; + jy += 33 * Math.floor(days / 12053); + days %= 12053; + jy += 4 * Math.floor(days / 1461); + days %= 1461; + if (days > 365) { + jy += Math.floor((days - 1) / 365); + days = (days - 1) % 365; + } + const jm = days < 186 ? 1 + Math.floor(days / 31) : 7 + Math.floor((days - 186) / 30); + const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30); + return `${jy}/${String(jm).padStart(2, '0')}/${String(jd).padStart(2, '0')}`; +}; + +const formatJalaliDate = (rawDate) => { + if (!rawDate) return ''; + const date = rawDate instanceof Date ? rawDate : new Date(rawDate); + if (Number.isNaN(date.getTime())) return ''; + return gregorianToJalali(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()); +}; + const utcDayRange = (value) => { const date = parseImportDate(value); if (!date) return null; @@ -111,6 +143,8 @@ const utcDayRange = (value) => { module.exports = { toEnglishDigits, jalaliToGregorian, + gregorianToJalali, + formatJalaliDate, parseJalaliDate, parseImportDate, utcDayRange diff --git a/utils/senders/smsMessages.js b/utils/senders/smsMessages.js index d83c028..04463b0 100644 --- a/utils/senders/smsMessages.js +++ b/utils/senders/smsMessages.js @@ -4,7 +4,7 @@ const { sendSingleSms } = require('./sms.base'); const { recordAndSend } = require('./notificationRecorder'); const { getSmsTemplate } = require('../../components/settings/settingService'); -const { buildSmsParameters } = require('../../components/settings/smsTemplates'); +const { buildSmsParameters, renderSmsText } = require('../../components/settings/smsTemplates'); const User = require('../../components/users/userModel'); const logger = require('../logger'); @@ -33,22 +33,28 @@ const sendTemplateSms = async ({ slotValues = {} }) => { const resolvedUserId = userId || await resolveUserIdByPhone(receiver); - const { templateId, variables, enabled } = await getSmsTemplate(templateKey); + const { templateId, variables, enabled, text: templateText } = await getSmsTemplate(templateKey); if (enabled === false) { logger.info(`[SMS] Template ${templateKey} is disabled. Skipping SMS send to ${receiver}.`); return { success: true, skipped: true, reason: 'template_disabled' }; } + + const paramsContext = { + phoneNumber: receiver, + mobile: receiver, + ...slotValues + }; + + const renderedExactBody = renderSmsText(templateText, variables, paramsContext); + const finalBody = (renderedExactBody && renderedExactBody.trim()) ? renderedExactBody.trim() : (body || subject || 'پیامک'); + return recordAndSend({ userId: resolvedUserId, channel: 'sms', subject, - body, + body: finalBody, relatedEvent, - sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { - phoneNumber: receiver, - mobile: receiver, - ...slotValues - })) + sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, paramsContext)) }); }; @@ -375,6 +381,27 @@ const sendCertificateIssuedSms = async (receiver, payload, userId = null) => { }); }; +const sendClassPlanProfessorSms = async (receiver, payload, userId = null) => { + const data = payload || {}; + return sendTemplateSms({ + templateKey: 'classPlanProfessor', + receiver, + userId, + subject: 'برنامه کلاس به استاد', + body: `استاد ${data.professorName || ''}، برنامه کلاس ${data.className || ''}: ${data.classDays || ''}، ${data.classTimes || ''} از ${data.classStartDate || ''} الی ${data.classEndDate || ''}`, + relatedEvent: 'class.plan_to_professor', + slotValues: { + professorName: data.professorName || 'استاد', + className: data.className || '-', + classDays: data.classDays || '', + classTimes: data.classTimes || '', + classStartDate: data.classStartDate || '', + classEndDate: data.classEndDate || '', + phoneNumber: receiver + } + }); +}; + module.exports = { sendAccountCreatedSms, sendPasswordResetSms, @@ -389,5 +416,6 @@ module.exports = { sendClassRequestApprovedSms, sendClassRequestRejectedSms, sendPendingRegistrationSms, - sendCertificateIssuedSms + sendCertificateIssuedSms, + sendClassPlanProfessorSms };