feat: add employee timing system, notification templates, and professor class plan sms
This commit is contained in:
@@ -106,6 +106,8 @@ const pendingStudentRoutes = require('./components/pendingStudents/pendingStuden
|
|||||||
const waitlistRoutes = require('./components/waitlist/waitlistRoutes');
|
const waitlistRoutes = require('./components/waitlist/waitlistRoutes');
|
||||||
app.use('/api/pending-students', pendingStudentRoutes);
|
app.use('/api/pending-students', pendingStudentRoutes);
|
||||||
app.use('/api/waitlist', waitlistRoutes);
|
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/seed', seedRoutes);
|
||||||
app.use('/api/data-import', dataImportRoutes);
|
app.use('/api/data-import', dataImportRoutes);
|
||||||
app.use('/api/settings', settingRoutes);
|
app.use('/api/settings', settingRoutes);
|
||||||
|
|||||||
@@ -59,3 +59,8 @@ exports.getPublicOne = catchAsync(async (req, res) => {
|
|||||||
const cls = await classService.getPublicOne(req.params.id);
|
const cls = await classService.getPublicOne(req.params.id);
|
||||||
return successResponse(res, 200, 'Class retrieved successfully', cls);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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/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.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.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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ const PendingStudent = require('../pendingStudents/pendingStudentModel');
|
|||||||
const Payment = require('../payments/paymentModel');
|
const Payment = require('../payments/paymentModel');
|
||||||
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, sendClassPlanProfessorSms } = require('../../utils/senders/smsMessages');
|
||||||
const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime, calculateClassEndDate } = require('../../utils/classSchedule');
|
const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime, calculateClassEndDate, formatClassDaysFromIndexes } = require('../../utils/classSchedule');
|
||||||
|
const { formatJalaliDate } = require('../../utils/jalaliDate');
|
||||||
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
|
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
|
||||||
const { notifyAction } = require('../../utils/actionNotify');
|
const { notifyAction } = require('../../utils/actionNotify');
|
||||||
const logger = require('../../utils/logger');
|
const logger = require('../../utils/logger');
|
||||||
@@ -294,4 +295,68 @@ const getPublicOne = async (id) => {
|
|||||||
return enrichClassForDisplay(cls);
|
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
|
||||||
|
};
|
||||||
|
|||||||
@@ -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), '—');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -65,6 +65,15 @@ const NOTIFICATION_ACTION_DEFS = [
|
|||||||
smsTemplateKey: 'sessionHolding',
|
smsTemplateKey: 'sessionHolding',
|
||||||
defaultChannels: { sms: true, email: false, bot: false }
|
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',
|
key: 'invoiceCreated',
|
||||||
label: 'ایجاد صورتحساب',
|
label: 'ایجاد صورتحساب',
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const toPublicTemplates = (storedMap, notificationMap = {}) => {
|
|||||||
return {
|
return {
|
||||||
key: def.key,
|
key: def.key,
|
||||||
label: def.label,
|
label: def.label,
|
||||||
|
category: entry.category || def.category || 'اطلاعرسانی',
|
||||||
|
text: entry.text || def.defaultText || '',
|
||||||
enabled: isEnabled,
|
enabled: isEnabled,
|
||||||
templateId: resolveTemplateId(entry, def),
|
templateId: resolveTemplateId(entry, def),
|
||||||
availableSlots: (def.slots || []).map((slot) => ({
|
availableSlots: (def.slots || []).map((slot) => ({
|
||||||
@@ -115,7 +117,7 @@ const getSettings = async () => {
|
|||||||
const getSmsTemplate = async (key) => {
|
const getSmsTemplate = async (key) => {
|
||||||
const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key);
|
const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key);
|
||||||
if (!def) {
|
if (!def) {
|
||||||
return { templateId: '', variables: [], enabled: true };
|
return { templateId: '', variables: [], enabled: true, text: '', category: 'اطلاعرسانی' };
|
||||||
}
|
}
|
||||||
const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
|
const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
|
||||||
const entry = normalizeStoredEntry(readStoredMap(doc)[key], def);
|
const entry = normalizeStoredEntry(readStoredMap(doc)[key], def);
|
||||||
@@ -128,6 +130,8 @@ const getSmsTemplate = async (key) => {
|
|||||||
return {
|
return {
|
||||||
enabled: isEnabled,
|
enabled: isEnabled,
|
||||||
templateId: resolveTemplateId(entry, def),
|
templateId: resolveTemplateId(entry, def),
|
||||||
|
category: entry.category || def.category || 'اطلاعرسانی',
|
||||||
|
text: entry.text || def.defaultText || '',
|
||||||
variables: resolveVariablesList(def, entry.variables)
|
variables: resolveVariablesList(def, entry.variables)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -140,12 +144,14 @@ const getSmsTemplateId = async (key) => {
|
|||||||
const parseIncomingEntry = (raw) => {
|
const parseIncomingEntry = (raw) => {
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
if (typeof raw === 'string' || typeof raw === 'number') {
|
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;
|
if (typeof raw !== 'object') return null;
|
||||||
return {
|
return {
|
||||||
enabled: raw.enabled !== undefined ? Boolean(raw.enabled) : undefined,
|
enabled: raw.enabled !== undefined ? Boolean(raw.enabled) : undefined,
|
||||||
templateId: raw.templateId,
|
templateId: raw.templateId,
|
||||||
|
category: raw.category,
|
||||||
|
text: raw.text,
|
||||||
variables: raw.variables
|
variables: raw.variables
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+347
-158
@@ -1,137 +1,17 @@
|
|||||||
|
// /components/settings/smsTemplates.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const SMS_TEMPLATE_DEFS = [
|
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',
|
key: 'sessionHolding',
|
||||||
label: 'برگزاری جلسه طبق برنامه',
|
label: 'کلاس طبق برنامه',
|
||||||
|
category: 'اطلاعرسانی',
|
||||||
|
defaultTemplateId: '720661',
|
||||||
envKey: 'SMS_TEMPLATE_SESSION_HOLDING',
|
envKey: 'SMS_TEMPLATE_SESSION_HOLDING',
|
||||||
|
defaultText: ` #FULLNAME# عزیز، «#TOPIC#» کلاس #CLASSNAME# در تاریخ #SESSIONDATE# و ساعت #CLASSTIME# طبق برنامه برگزار خواهد شد.
|
||||||
|
|
||||||
|
آموزشگاه گام نو
|
||||||
|
game-no.ir`,
|
||||||
slots: [
|
slots: [
|
||||||
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
|
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
|
||||||
@@ -144,14 +24,245 @@ const SMS_TEMPLATE_DEFS = [
|
|||||||
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
{ 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',
|
key: 'classRequestApproved',
|
||||||
label: 'تأیید درخواست تشکیل کلاس',
|
label: 'تأیید درخواست تشکیل کلاس',
|
||||||
|
category: 'ثبتنام و درخواستها',
|
||||||
|
defaultTemplateId: '',
|
||||||
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED',
|
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED',
|
||||||
defaultEnabled: false,
|
defaultEnabled: false,
|
||||||
|
defaultText: ` #FULLNAME# عزیز، درخواست شما برای تشکیل کلاس #COURSENAME# تأیید شد.
|
||||||
|
|
||||||
|
آموزشگاه گام نو
|
||||||
|
game-no.ir`,
|
||||||
slots: [
|
slots: [
|
||||||
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
{ key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
|
||||||
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
|
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
|
||||||
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
]
|
]
|
||||||
@@ -159,12 +270,19 @@ const SMS_TEMPLATE_DEFS = [
|
|||||||
{
|
{
|
||||||
key: 'classRequestRejected',
|
key: 'classRequestRejected',
|
||||||
label: 'رد درخواست ثبتنام',
|
label: 'رد درخواست ثبتنام',
|
||||||
|
category: 'ثبتنام و درخواستها',
|
||||||
|
defaultTemplateId: '',
|
||||||
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED',
|
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED',
|
||||||
defaultEnabled: false,
|
defaultEnabled: false,
|
||||||
|
defaultText: ` #FULLNAME# عزیز، درخواست ثبتنام شما برای دوره #COURSENAME# مورد پذیرش قرار نگرفت.
|
||||||
|
دلیل: #REASON#
|
||||||
|
|
||||||
|
آموزشگاه گام نو
|
||||||
|
game-no.ir`,
|
||||||
slots: [
|
slots: [
|
||||||
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
{ key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
|
||||||
{ key: 'reason', label: 'دلیل', defaultName: 'reason' },
|
{ key: 'reason', label: 'دلیل', defaultName: 'REASON' },
|
||||||
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
|
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
|
||||||
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
]
|
]
|
||||||
@@ -172,24 +290,18 @@ const SMS_TEMPLATE_DEFS = [
|
|||||||
{
|
{
|
||||||
key: 'pendingRegistration',
|
key: 'pendingRegistration',
|
||||||
label: 'دریافت درخواست ثبتنام',
|
label: 'دریافت درخواست ثبتنام',
|
||||||
|
category: 'ثبتنام و درخواستها',
|
||||||
|
defaultTemplateId: '',
|
||||||
envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION',
|
envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION',
|
||||||
defaultEnabled: false,
|
defaultEnabled: false,
|
||||||
|
defaultText: ` #FULLNAME# عزیز، درخواست ثبتنام شما در کلاس #CLASSNAME# با کد پیگیری #REGISTRATIONCODE# دریافت شد.
|
||||||
|
|
||||||
|
آموزشگاه گام نو
|
||||||
|
game-no.ir`,
|
||||||
slots: [
|
slots: [
|
||||||
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
|
{ key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
|
||||||
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
|
{ 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: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -214,12 +326,15 @@ const sanitizeVariableName = (value) => {
|
|||||||
if (value == null) return '';
|
if (value == null) return '';
|
||||||
const name = String(value).trim().replace(/^#+|#+$/g, '').trim();
|
const name = String(value).trim().replace(/^#+|#+$/g, '').trim();
|
||||||
if (!name) return '';
|
if (!name) return '';
|
||||||
// Check for dangerous injection or control chars
|
|
||||||
if (/[\r\n\t\0<>"'`]/.test(name)) return null;
|
if (/[\r\n\t\0<>"'`]/.test(name)) return null;
|
||||||
if (name.length > 100) return null;
|
if (name.length > 100) return null;
|
||||||
return name;
|
return name;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeRegExp = (string) => {
|
||||||
|
return String(string).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
const defaultVariablesList = (def) => {
|
const defaultVariablesList = (def) => {
|
||||||
return (def?.slots || []).map((slot) => ({
|
return (def?.slots || []).map((slot) => ({
|
||||||
slot: slot.key,
|
slot: slot.key,
|
||||||
@@ -229,7 +344,9 @@ const defaultVariablesList = (def) => {
|
|||||||
|
|
||||||
const emptyTemplateEntry = (def) => ({
|
const emptyTemplateEntry = (def) => ({
|
||||||
enabled: def ? (def.defaultEnabled !== false) : true,
|
enabled: def ? (def.defaultEnabled !== false) : true,
|
||||||
templateId: '',
|
templateId: def?.defaultTemplateId || '',
|
||||||
|
category: def?.category || 'اطلاعرسانی',
|
||||||
|
text: def?.defaultText || '',
|
||||||
variables: defaultVariablesList(def)
|
variables: defaultVariablesList(def)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,10 +388,16 @@ const normalizeVariablesInput = (rawVariables, def) => {
|
|||||||
|
|
||||||
const normalizeStoredEntry = (raw, def) => {
|
const normalizeStoredEntry = (raw, def) => {
|
||||||
const defaultEnabled = def ? (def.defaultEnabled !== false) : true;
|
const defaultEnabled = def ? (def.defaultEnabled !== false) : true;
|
||||||
|
const defaultText = def?.defaultText || '';
|
||||||
|
const defaultTemplateId = def?.defaultTemplateId || '';
|
||||||
|
const defaultCategory = def?.category || 'اطلاعرسانی';
|
||||||
|
|
||||||
if (raw == null || raw === '') {
|
if (raw == null || raw === '') {
|
||||||
return {
|
return {
|
||||||
enabled: defaultEnabled,
|
enabled: defaultEnabled,
|
||||||
templateId: '',
|
templateId: defaultTemplateId,
|
||||||
|
category: defaultCategory,
|
||||||
|
text: defaultText,
|
||||||
variables: def ? defaultVariablesList(def) : []
|
variables: def ? defaultVariablesList(def) : []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -282,13 +405,17 @@ const normalizeStoredEntry = (raw, def) => {
|
|||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
templateId: String(raw),
|
templateId: String(raw),
|
||||||
|
category: defaultCategory,
|
||||||
|
text: defaultText,
|
||||||
variables: def ? defaultVariablesList(def) : []
|
variables: def ? defaultVariablesList(def) : []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (typeof raw !== 'object') {
|
if (typeof raw !== 'object') {
|
||||||
return {
|
return {
|
||||||
enabled: defaultEnabled,
|
enabled: defaultEnabled,
|
||||||
templateId: '',
|
templateId: defaultTemplateId,
|
||||||
|
category: defaultCategory,
|
||||||
|
text: defaultText,
|
||||||
variables: def ? defaultVariablesList(def) : []
|
variables: def ? defaultVariablesList(def) : []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -301,9 +428,21 @@ const normalizeStoredEntry = (raw, def) => {
|
|||||||
? Boolean(raw.enabled)
|
? Boolean(raw.enabled)
|
||||||
: defaultEnabled;
|
: 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 {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
templateId: raw.templateId != null ? String(raw.templateId) : '',
|
templateId,
|
||||||
|
category,
|
||||||
|
text,
|
||||||
variables
|
variables
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -315,15 +454,22 @@ const parseIncomingVariables = (rawVariables, def) => {
|
|||||||
|
|
||||||
const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
|
const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
|
||||||
const storedEntry = normalizeStoredEntry(storedRaw, def);
|
const storedEntry = normalizeStoredEntry(storedRaw, def);
|
||||||
let templateId = storedEntry.templateId || envFallbackFor(def);
|
let templateId = storedEntry.templateId || envFallbackFor(def) || def?.defaultTemplateId || '';
|
||||||
let variables = storedEntry.variables;
|
let variables = storedEntry.variables;
|
||||||
let enabled = storedEntry.enabled;
|
let enabled = storedEntry.enabled;
|
||||||
|
let text = storedEntry.text || def?.defaultText || '';
|
||||||
|
let category = storedEntry.category || def?.category || 'اطلاعرسانی';
|
||||||
|
|
||||||
if (incomingRaw) {
|
if (incomingRaw) {
|
||||||
if (incomingRaw.enabled !== undefined) {
|
if (incomingRaw.enabled !== undefined) {
|
||||||
enabled = Boolean(incomingRaw.enabled);
|
enabled = Boolean(incomingRaw.enabled);
|
||||||
}
|
}
|
||||||
|
if (incomingRaw.text !== undefined) {
|
||||||
|
text = String(incomingRaw.text || '');
|
||||||
|
}
|
||||||
|
if (incomingRaw.category !== undefined) {
|
||||||
|
category = String(incomingRaw.category || '');
|
||||||
|
}
|
||||||
if (incomingRaw.templateId !== undefined) {
|
if (incomingRaw.templateId !== undefined) {
|
||||||
const sanitized = sanitizeTemplateId(incomingRaw.templateId);
|
const sanitized = sanitizeTemplateId(incomingRaw.templateId);
|
||||||
if (sanitized === null) {
|
if (sanitized === null) {
|
||||||
@@ -334,7 +480,6 @@ const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
|
|||||||
}
|
}
|
||||||
templateId = sanitized;
|
templateId = sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incomingRaw.variables !== undefined) {
|
if (incomingRaw.variables !== undefined) {
|
||||||
const parsed = parseIncomingVariables(incomingRaw.variables, def) || [];
|
const parsed = parseIncomingVariables(incomingRaw.variables, def) || [];
|
||||||
for (const item of parsed) {
|
for (const item of parsed) {
|
||||||
@@ -350,12 +495,14 @@ const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
|
|||||||
variables = parsed;
|
variables = parsed;
|
||||||
}
|
}
|
||||||
} else if (!storedEntry.templateId) {
|
} else if (!storedEntry.templateId) {
|
||||||
templateId = envFallbackFor(def);
|
templateId = envFallbackFor(def) || def?.defaultTemplateId || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
templateId,
|
templateId,
|
||||||
|
category,
|
||||||
|
text,
|
||||||
variables: resolveVariablesList(def, variables)
|
variables: resolveVariablesList(def, variables)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -417,6 +564,46 @@ const buildSmsParameters = (variables, valuesBySlot = {}) => {
|
|||||||
.filter(Boolean);
|
.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 = {
|
module.exports = {
|
||||||
SMS_TEMPLATE_DEFS,
|
SMS_TEMPLATE_DEFS,
|
||||||
SMS_TEMPLATE_KEYS,
|
SMS_TEMPLATE_KEYS,
|
||||||
@@ -429,5 +616,7 @@ module.exports = {
|
|||||||
parseIncomingVariables,
|
parseIncomingVariables,
|
||||||
mergeTemplateEntry,
|
mergeTemplateEntry,
|
||||||
resolveVariablesList,
|
resolveVariablesList,
|
||||||
buildSmsParameters
|
buildSmsParameters,
|
||||||
|
renderSmsText,
|
||||||
|
formatPriceValue
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,7 +105,13 @@ const PERMISSIONS = {
|
|||||||
WAITLIST_CREATE: 'waitlist:create',
|
WAITLIST_CREATE: 'waitlist:create',
|
||||||
WAITLIST_READ: 'waitlist:read',
|
WAITLIST_READ: 'waitlist:read',
|
||||||
WAITLIST_UPDATE: 'waitlist:update',
|
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);
|
const ALL_PERMISSIONS = Object.values(PERMISSIONS);
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"start": "node app.js",
|
"start": "node app.js",
|
||||||
"dev": "nodemon app.js",
|
"dev": "nodemon app.js",
|
||||||
"seed": "node seed.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": [
|
"keywords": [
|
||||||
"express",
|
"express",
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ const defaultRoles = [
|
|||||||
PERMISSIONS.WAITLIST_CREATE,
|
PERMISSIONS.WAITLIST_CREATE,
|
||||||
PERMISSIONS.WAITLIST_READ,
|
PERMISSIONS.WAITLIST_READ,
|
||||||
PERMISSIONS.WAITLIST_UPDATE,
|
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
|
isSystem: true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,6 +100,38 @@ const parseImportDate = (raw) => {
|
|||||||
return Number.isNaN(date.getTime()) ? null : date;
|
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 utcDayRange = (value) => {
|
||||||
const date = parseImportDate(value);
|
const date = parseImportDate(value);
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
@@ -111,6 +143,8 @@ const utcDayRange = (value) => {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
toEnglishDigits,
|
toEnglishDigits,
|
||||||
jalaliToGregorian,
|
jalaliToGregorian,
|
||||||
|
gregorianToJalali,
|
||||||
|
formatJalaliDate,
|
||||||
parseJalaliDate,
|
parseJalaliDate,
|
||||||
parseImportDate,
|
parseImportDate,
|
||||||
utcDayRange
|
utcDayRange
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
const { sendSingleSms } = require('./sms.base');
|
const { sendSingleSms } = require('./sms.base');
|
||||||
const { recordAndSend } = require('./notificationRecorder');
|
const { recordAndSend } = require('./notificationRecorder');
|
||||||
const { getSmsTemplate } = require('../../components/settings/settingService');
|
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 User = require('../../components/users/userModel');
|
||||||
const logger = require('../logger');
|
const logger = require('../logger');
|
||||||
|
|
||||||
@@ -33,22 +33,28 @@ const sendTemplateSms = async ({
|
|||||||
slotValues = {}
|
slotValues = {}
|
||||||
}) => {
|
}) => {
|
||||||
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
|
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) {
|
if (enabled === false) {
|
||||||
logger.info(`[SMS] Template ${templateKey} is disabled. Skipping SMS send to ${receiver}.`);
|
logger.info(`[SMS] Template ${templateKey} is disabled. Skipping SMS send to ${receiver}.`);
|
||||||
return { success: true, skipped: true, reason: 'template_disabled' };
|
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({
|
return recordAndSend({
|
||||||
userId: resolvedUserId,
|
userId: resolvedUserId,
|
||||||
channel: 'sms',
|
channel: 'sms',
|
||||||
subject,
|
subject,
|
||||||
body,
|
body: finalBody,
|
||||||
relatedEvent,
|
relatedEvent,
|
||||||
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
|
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, paramsContext))
|
||||||
phoneNumber: receiver,
|
|
||||||
mobile: receiver,
|
|
||||||
...slotValues
|
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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 = {
|
module.exports = {
|
||||||
sendAccountCreatedSms,
|
sendAccountCreatedSms,
|
||||||
sendPasswordResetSms,
|
sendPasswordResetSms,
|
||||||
@@ -389,5 +416,6 @@ module.exports = {
|
|||||||
sendClassRequestApprovedSms,
|
sendClassRequestApprovedSms,
|
||||||
sendClassRequestRejectedSms,
|
sendClassRequestRejectedSms,
|
||||||
sendPendingRegistrationSms,
|
sendPendingRegistrationSms,
|
||||||
sendCertificateIssuedSms
|
sendCertificateIssuedSms,
|
||||||
|
sendClassPlanProfessorSms
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user