feat(professors,sms): merge professor with users, add existing user link, professor portal endpoint, and SMS bypass list
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user