feat(professors,sms): merge professor with users, add existing user link, professor portal endpoint, and SMS bypass list

This commit is contained in:
2026-08-25 03:48:19 +03:30
parent b708a6b777
commit 0a022c0917
14 changed files with 865 additions and 205 deletions
+53 -5
View File
@@ -13,17 +13,32 @@ let cache = null;
let cacheAt = 0;
const CACHE_TTL_MS = 10000;
const normalizePhoneForBypass = (raw = '') => {
if (!raw) return '';
let digits = String(raw)
.replace(/[۰-۹]/g, (d) => '0123456789'['۰۱۲۳۴۵۶۷۸۹'.indexOf(d)])
.replace(/[٠-٩]/g, (d) => '0123456789'['٠١٢٣٤٥٦٧٨٩'.indexOf(d)])
.replace(/\D/g, '');
if (digits.startsWith('98') && digits.length >= 12) digits = `0${digits.slice(2)}`;
if (digits.length === 10 && digits.startsWith('9')) digits = `0${digits}`;
if (digits.length > 11 && digits.startsWith('09')) digits = digits.slice(0, 11);
return digits;
};
const readDbFlags = (doc) => ({
smsEnabled: doc?.smsEnabled,
emailEnabled: doc?.emailEnabled,
botEnabled: doc?.botEnabled
botEnabled: doc?.botEnabled,
smsBypassNumbers: Array.isArray(doc?.smsBypassNumbers)
? doc.smsBypassNumbers.map((item) => (item.toObject ? item.toObject() : item))
: []
});
const getDbMessagingFlags = async () => {
const now = Date.now();
if (cache && (now - cacheAt) < CACHE_TTL_MS) return cache;
const doc = await Setting.findOne({ key: SETTINGS_KEY })
.select('smsEnabled emailEnabled botEnabled')
.select('smsEnabled emailEnabled botEnabled smsBypassNumbers')
.lean();
cache = readDbFlags(doc);
cacheAt = now;
@@ -35,13 +50,42 @@ const invalidateMessagingCache = () => {
cacheAt = 0;
};
const getMessagingChannelState = async (channel) => {
const isSmsBypassNumber = async (phoneNumber) => {
if (!phoneNumber) return false;
const target = normalizePhoneForBypass(phoneNumber);
if (!target) return false;
try {
const db = await getDbMessagingFlags();
const bypassList = Array.isArray(db.smsBypassNumbers) ? db.smsBypassNumbers : [];
return bypassList.some((item) => item.isActive !== false && normalizePhoneForBypass(item.phoneNumber) === target);
} catch (err) {
logger.warn(`[Messaging] Failed to check bypass numbers: ${err.message}`);
return false;
}
};
const getMessagingChannelState = async (channel, recipientPhone = null) => {
const envEnabled = isEnvChannelEnabled(channel, config);
const bypass = channel === 'sms' && recipientPhone ? await isSmsBypassNumber(recipientPhone) : false;
if (bypass) {
logger.info(`[Messaging] Recipient ${recipientPhone} is in SMS bypass list. Channel bypass activated.`);
return {
enabled: true,
envEnabled,
dbEnabled: true,
bypass: true,
blockReason: null
};
}
if (!envEnabled) {
return {
enabled: false,
envEnabled: false,
dbEnabled: null,
bypass: false,
blockReason: 'env_disabled'
};
}
@@ -53,6 +97,7 @@ const getMessagingChannelState = async (channel) => {
enabled: dbEnabled,
envEnabled: true,
dbEnabled,
bypass: false,
blockReason: dbEnabled ? null : 'dashboard_disabled'
};
} catch (err) {
@@ -61,13 +106,14 @@ const getMessagingChannelState = async (channel) => {
enabled: true,
envEnabled: true,
dbEnabled: null,
bypass: false,
blockReason: null
};
}
};
const isMessagingChannelEnabled = async (channel) => {
const state = await getMessagingChannelState(channel);
const isMessagingChannelEnabled = async (channel, recipientPhone = null) => {
const state = await getMessagingChannelState(channel, recipientPhone);
return state.enabled;
};
@@ -76,6 +122,8 @@ const getPublicMessaging = (doc) => toPublicMessaging(readDbFlags(doc), config);
module.exports = {
getMessagingChannelState,
isMessagingChannelEnabled,
isSmsBypassNumber,
normalizePhoneForBypass,
invalidateMessagingCache,
getPublicMessaging,
getDbMessagingFlags
+27
View File
@@ -4,6 +4,29 @@ const mongoose = require('mongoose');
const SETTINGS_KEY = 'app';
const bypassNumberSchema = new mongoose.Schema({
phoneNumber: {
type: String,
required: true,
trim: true
},
label: {
type: String,
trim: true,
default: ''
},
isActive: {
type: Boolean,
default: true
},
createdAt: {
type: Date,
default: Date.now
}
}, {
_id: true
});
const settingSchema = new mongoose.Schema({
key: {
type: String,
@@ -28,6 +51,10 @@ const settingSchema = new mongoose.Schema({
type: Boolean,
default: true
},
smsBypassNumbers: {
type: [bypassNumberSchema],
default: []
},
notificationSettings: {
type: mongoose.Schema.Types.Mixed,
default: {}
+42 -4
View File
@@ -13,7 +13,8 @@ const {
const { parseIncomingMessaging } = require('../../utils/messagingChannels');
const {
getPublicMessaging,
invalidateMessagingCache
invalidateMessagingCache,
normalizePhoneForBypass
} = require('./messagingFlags');
const {
emptyNotificationSettingsMap,
@@ -82,13 +83,28 @@ const toPublicTemplates = (storedMap, notificationMap = {}) => {
});
};
const formatBypassNumbers = (list) => {
if (!Array.isArray(list)) return [];
return list.map((item) => {
const raw = item.toObject ? item.toObject() : item;
return {
_id: raw._id,
phoneNumber: raw.phoneNumber,
label: raw.label || '',
isActive: raw.isActive !== false,
createdAt: raw.createdAt
};
});
};
const getSettings = async () => {
let doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
if (!doc) {
const created = await Setting.create({
key: SETTINGS_KEY,
smsTemplates: emptyTemplateMap(),
notificationSettings: emptyNotificationSettingsMap()
notificationSettings: emptyNotificationSettingsMap(),
smsBypassNumbers: []
});
doc = created.toObject();
} else {
@@ -110,7 +126,8 @@ const getSettings = async () => {
return {
smsTemplates: toPublicTemplates(storedMap, notificationMap),
messaging: getPublicMessaging(doc),
notificationSettings: await getPublicNotificationSettings(doc)
notificationSettings: await getPublicNotificationSettings(doc),
smsBypassNumbers: formatBypassNumbers(doc.smsBypassNumbers)
};
};
@@ -250,6 +267,26 @@ const saveSettings = async (body = {}) => {
doc.set('notificationSettings', emptyNotificationSettingsMap());
}
// Handle smsBypassNumbers
if (Array.isArray(body.smsBypassNumbers)) {
const nextBypass = body.smsBypassNumbers
.map((item) => {
const rawPhone = item.phoneNumber || item.phone;
const normalized = normalizePhoneForBypass(rawPhone);
return {
_id: item._id || undefined,
phoneNumber: normalized || rawPhone,
label: String(item.label || '').trim(),
isActive: item.isActive !== false,
createdAt: item.createdAt || new Date()
};
})
.filter((item) => item.phoneNumber && item.phoneNumber.length >= 10);
doc.set('smsBypassNumbers', nextBypass);
doc.markModified('smsBypassNumbers');
}
await doc.save();
invalidateMessagingCache();
invalidateNotificationSettingsCache();
@@ -259,7 +296,8 @@ const saveSettings = async (body = {}) => {
return {
smsTemplates: toPublicTemplates(readStoredMap(saved), finalNotificationMap),
messaging: getPublicMessaging(saved),
notificationSettings: await getPublicNotificationSettings(saved)
notificationSettings: await getPublicNotificationSettings(saved),
smsBypassNumbers: formatBypassNumbers(saved.smsBypassNumbers)
};
};
+37
View File
@@ -0,0 +1,37 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { normalizePhoneForBypass } = require('./messagingFlags');
test('SMS Bypass Numbers Utilities', async (t) => {
await t.test('normalizePhoneForBypass handles various phone formats and Persian digits', () => {
assert.equal(normalizePhoneForBypass('۰۹۱۲۳۴۵۶۷۸۹'), '09123456789');
assert.equal(normalizePhoneForBypass('+989123456789'), '09123456789');
assert.equal(normalizePhoneForBypass('989123456789'), '09123456789');
assert.equal(normalizePhoneForBypass('9123456789'), '09123456789');
assert.equal(normalizePhoneForBypass('0912-345-6789'), '09123456789');
assert.equal(normalizePhoneForBypass(''), '');
assert.equal(normalizePhoneForBypass(null), '');
});
await t.test('bypass list matches normalized phone numbers correctly', () => {
const bypassList = [
{ phoneNumber: '09123456789', label: 'مدیریت', isActive: true },
{ phoneNumber: '09351112233', label: 'پشتیبان غیرفعال', isActive: false }
];
const isMatch = (targetPhone) => {
const normalized = normalizePhoneForBypass(targetPhone);
return bypassList.some(
(item) => item.isActive !== false && normalizePhoneForBypass(item.phoneNumber) === normalized
);
};
assert.equal(isMatch('09123456789'), true);
assert.equal(isMatch('+989123456789'), true);
assert.equal(isMatch('۰۹۱۲۳۴۵۶۷۸۹'), true);
assert.equal(isMatch('09351112233'), false); // Inactive
assert.equal(isMatch('09100000000'), false); // Not in list
});
});