Extract magic numbers to centralized constants module

Created backend/src/config/constants.js to centralize all magic numbers
and configuration values used throughout the backend codebase.

Changes:
- Created constants.js with organized sections for:
  - SECURITY: JWT, rate limiting, password reset
  - AUTH: OTP configuration and rate limiting
  - SERVER: Port, body limits, startup delays
  - MQTT: Connection settings, cache limits
  - NOTIFICATIONS: Push settings, quiet hours, batching
  - SERIAL: Validation patterns and constraints
  - EMAIL: Template settings and defaults
  - CRON: Schedule configurations
  - STORAGE: Avatar storage settings

- Updated files to use constants:
  - index.js: JWT validation, rate limits, startup delays
  - routes/auth.js: OTP generation, rate limits, JWT expiry
  - services/mqtt.js: Connection timeouts, cache size
  - services/notifications.js: Batch size, TTL, quiet hours
  - utils/serialValidation.js: Serial number constraints

- Added comprehensive test suite (30 tests) for constants module
  - All tests passing (93 total including existing tests)
  - Validates reasonable values and consistency between related constants

Benefits:
- Single source of truth for configuration values
- Easier to maintain and update settings
- Better documentation of what each value represents
- Improved code readability by removing hardcoded numbers
- Testable configuration values

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-29 11:52:47 -08:00
parent 8456e85cfe
commit bbb60a9e3f
7 changed files with 413 additions and 48 deletions

View File

@ -0,0 +1,206 @@
/**
* Tests for constants module
*/
const {
SECURITY,
AUTH,
SERVER,
MQTT,
NOTIFICATIONS,
SERIAL,
EMAIL,
CRON,
STORAGE,
} = require('../constants');
describe('Constants Module', () => {
describe('SECURITY', () => {
it('should have valid JWT configuration', () => {
expect(SECURITY.JWT_MIN_SECRET_LENGTH).toBe(32);
expect(SECURITY.JWT_DEFAULT_EXPIRES_IN).toBe('7d');
});
it('should have valid rate limiting configuration', () => {
expect(SECURITY.RATE_LIMIT.WINDOW_MS).toBe(15 * 60 * 1000);
expect(SECURITY.RATE_LIMIT.MAX_REQUESTS).toBe(100);
expect(SECURITY.RATE_LIMIT.AUTH_MAX_REQUESTS).toBe(30);
});
it('should have password reset configuration', () => {
expect(SECURITY.PASSWORD_RESET_TOKEN_EXPIRY_HOURS).toBe(1);
});
});
describe('AUTH', () => {
it('should have valid OTP configuration', () => {
expect(AUTH.OTP_CODE_LENGTH).toBe(6);
expect(AUTH.OTP_CODE_MIN).toBe(100000);
expect(AUTH.OTP_CODE_MAX).toBe(999999);
expect(AUTH.OTP_EXPIRY_MINUTES).toBe(10);
expect(AUTH.OTP_EXPIRY_MS).toBe(10 * 60 * 1000);
});
it('should have OTP rate limiting configuration', () => {
expect(AUTH.OTP_VERIFY_MAX_ATTEMPTS).toBe(5);
expect(AUTH.OTP_VERIFY_WINDOW_MS).toBe(15 * 60 * 1000);
expect(AUTH.OTP_REQUEST_MAX_ATTEMPTS).toBe(3);
expect(AUTH.OTP_REQUEST_WINDOW_MS).toBe(15 * 60 * 1000);
});
it('should ensure OTP code range is 6 digits', () => {
expect(AUTH.OTP_CODE_MIN.toString().length).toBe(6);
expect(AUTH.OTP_CODE_MAX.toString().length).toBe(6);
});
});
describe('SERVER', () => {
it('should have valid server configuration', () => {
expect(SERVER.DEFAULT_PORT).toBe(3000);
expect(SERVER.JSON_BODY_LIMIT).toBe('10mb');
expect(SERVER.TRUST_PROXY_LEVEL).toBe(1);
});
it('should have valid startup delays', () => {
expect(SERVER.INITIAL_SYNC_DELAY_MS).toBe(10000);
expect(SERVER.MQTT_SUBSCRIBE_DELAY_MS).toBe(3000);
});
});
describe('MQTT', () => {
it('should have valid connection settings', () => {
expect(MQTT.RECONNECT_PERIOD_MS).toBe(5000);
expect(MQTT.KEEPALIVE_SECONDS).toBe(60);
expect(MQTT.DEFAULT_PORT).toBe(1883);
});
it('should have valid cache settings', () => {
expect(MQTT.MAX_ALERTS_CACHE).toBe(100);
expect(MQTT.DEFAULT_ALERTS_LIMIT).toBe(50);
});
});
describe('NOTIFICATIONS', () => {
it('should have valid push notification settings', () => {
expect(NOTIFICATIONS.BATCH_SIZE).toBe(100);
expect(NOTIFICATIONS.DEFAULT_TTL_SECONDS).toBe(86400);
expect(NOTIFICATIONS.DEFAULT_PRIORITY).toBe('high');
expect(NOTIFICATIONS.DEFAULT_SOUND).toBe('default');
});
it('should have valid channel IDs', () => {
expect(NOTIFICATIONS.DEFAULT_CHANNEL_ID).toBe('default');
expect(NOTIFICATIONS.EMERGENCY_CHANNEL_ID).toBe('emergency');
});
it('should have valid quiet hours defaults', () => {
expect(NOTIFICATIONS.DEFAULT_QUIET_START).toBe('22:00');
expect(NOTIFICATIONS.DEFAULT_QUIET_END).toBe('07:00');
});
it('should have valid history settings', () => {
expect(NOTIFICATIONS.HISTORY_DEFAULT_LIMIT).toBe(50);
expect(NOTIFICATIONS.HISTORY_DEFAULT_OFFSET).toBe(0);
});
it('should have time conversion constant', () => {
expect(NOTIFICATIONS.MINUTES_PER_HOUR).toBe(60);
});
});
describe('SERIAL', () => {
it('should have valid serial number constraints', () => {
expect(SERIAL.MIN_LENGTH).toBe(8);
expect(SERIAL.PRODUCTION_LENGTH).toBe(16);
});
it('should have valid regex patterns', () => {
expect(SERIAL.PRODUCTION_PATTERN).toBeInstanceOf(RegExp);
expect(SERIAL.LEGACY_PATTERN).toBeInstanceOf(RegExp);
expect(SERIAL.VALID_CHARS_PATTERN).toBeInstanceOf(RegExp);
});
it('should validate production format correctly', () => {
expect(SERIAL.PRODUCTION_PATTERN.test('WELLNUO-1234-5678')).toBe(true);
expect(SERIAL.PRODUCTION_PATTERN.test('WELLNUO-ABCD-EFGH')).toBe(true);
expect(SERIAL.PRODUCTION_PATTERN.test('WELLNUO-1234-567')).toBe(false);
expect(SERIAL.PRODUCTION_PATTERN.test('INVALID-1234-5678')).toBe(false);
});
it('should validate legacy format correctly', () => {
expect(SERIAL.LEGACY_PATTERN.test('12345678')).toBe(true);
expect(SERIAL.LEGACY_PATTERN.test('ABC-12345')).toBe(true);
expect(SERIAL.LEGACY_PATTERN.test('1234567')).toBe(false);
});
it('should validate character set correctly', () => {
expect(SERIAL.VALID_CHARS_PATTERN.test('ABC123-XYZ')).toBe(true);
expect(SERIAL.VALID_CHARS_PATTERN.test('ABC@123')).toBe(false);
});
});
describe('EMAIL', () => {
it('should have valid template settings', () => {
expect(EMAIL.MAX_CONTAINER_WIDTH).toBe(600);
expect(EMAIL.LOGO_FONT_SIZE).toBe(24);
expect(EMAIL.TITLE_FONT_SIZE).toBe(20);
});
it('should have valid OTP email settings', () => {
expect(EMAIL.OTP_CODE_FONT_SIZE).toBe(32);
expect(EMAIL.OTP_CODE_LETTER_SPACING).toBe(6);
expect(EMAIL.OTP_EXPIRY_TEXT_MINUTES).toBe(10);
});
it('should have valid Brevo defaults', () => {
expect(EMAIL.DEFAULT_SENDER_NAME).toBe('WellNuo');
expect(EMAIL.DEFAULT_SENDER_EMAIL).toBe('noreply@wellnuo.com');
});
});
describe('CRON', () => {
it('should have valid cron schedule', () => {
expect(CRON.SYNC_CRON_SCHEDULE).toBe('0 * * * *');
});
});
describe('STORAGE', () => {
it('should have valid avatar settings', () => {
expect(STORAGE.AVATAR_FILENAME_PREFIX).toBe('user-');
expect(STORAGE.AVATAR_FOLDER).toBe('avatars/users');
});
});
describe('Time consistency', () => {
it('should have consistent OTP expiry times', () => {
expect(AUTH.OTP_EXPIRY_MS).toBe(AUTH.OTP_EXPIRY_MINUTES * 60 * 1000);
});
it('should have consistent rate limit windows', () => {
expect(AUTH.OTP_VERIFY_WINDOW_MS).toBe(SECURITY.RATE_LIMIT.WINDOW_MS);
expect(AUTH.OTP_REQUEST_WINDOW_MS).toBe(SECURITY.RATE_LIMIT.WINDOW_MS);
});
});
describe('Reasonable values', () => {
it('should have reasonable timeout values', () => {
// Timeouts should be positive and not too large
expect(MQTT.RECONNECT_PERIOD_MS).toBeGreaterThan(0);
expect(MQTT.RECONNECT_PERIOD_MS).toBeLessThan(60000);
expect(SERVER.INITIAL_SYNC_DELAY_MS).toBeGreaterThan(0);
expect(SERVER.INITIAL_SYNC_DELAY_MS).toBeLessThan(60000);
});
it('should have reasonable cache limits', () => {
expect(MQTT.MAX_ALERTS_CACHE).toBeGreaterThan(0);
expect(MQTT.MAX_ALERTS_CACHE).toBeLessThan(1000);
expect(MQTT.DEFAULT_ALERTS_LIMIT).toBeGreaterThan(0);
expect(MQTT.DEFAULT_ALERTS_LIMIT).toBeLessThanOrEqual(MQTT.MAX_ALERTS_CACHE);
});
it('should have reasonable batch sizes', () => {
expect(NOTIFICATIONS.BATCH_SIZE).toBeGreaterThan(0);
expect(NOTIFICATIONS.BATCH_SIZE).toBeLessThanOrEqual(1000);
});
});
});

View File

@ -0,0 +1,152 @@
/**
* Application Constants
*
* Centralized configuration for magic numbers used throughout the backend.
* All time values are in milliseconds unless otherwise specified.
*/
// ============ SECURITY ============
const SECURITY = {
// JWT Configuration
JWT_MIN_SECRET_LENGTH: 32,
JWT_DEFAULT_EXPIRES_IN: '7d',
// Password Reset
PASSWORD_RESET_TOKEN_EXPIRY_HOURS: 1,
// Rate Limiting
RATE_LIMIT: {
WINDOW_MS: 15 * 60 * 1000, // 15 minutes
MAX_REQUESTS: 100,
AUTH_MAX_REQUESTS: 30,
},
};
// ============ AUTHENTICATION ============
const AUTH = {
// OTP Configuration
OTP_CODE_LENGTH: 6,
OTP_CODE_MIN: 100000,
OTP_CODE_MAX: 999999,
OTP_EXPIRY_MINUTES: 10,
OTP_EXPIRY_MS: 10 * 60 * 1000,
// Rate Limiting for OTP
OTP_VERIFY_MAX_ATTEMPTS: 5,
OTP_VERIFY_WINDOW_MS: 15 * 60 * 1000, // 15 minutes
OTP_REQUEST_MAX_ATTEMPTS: 3,
OTP_REQUEST_WINDOW_MS: 15 * 60 * 1000, // 15 minutes
};
// ============ SERVER ============
const SERVER = {
// Default Port
DEFAULT_PORT: 3000,
// Body Parser Limits
JSON_BODY_LIMIT: '10mb',
// Startup Delays
INITIAL_SYNC_DELAY_MS: 10000, // 10 seconds
MQTT_SUBSCRIBE_DELAY_MS: 3000, // 3 seconds
// Trust Proxy
TRUST_PROXY_LEVEL: 1,
};
// ============ MQTT ============
const MQTT = {
// Connection Settings
RECONNECT_PERIOD_MS: 5000, // 5 seconds
KEEPALIVE_SECONDS: 60,
// Alert Cache
MAX_ALERTS_CACHE: 100,
DEFAULT_ALERTS_LIMIT: 50,
// Default Broker Settings
DEFAULT_PORT: 1883,
};
// ============ NOTIFICATIONS ============
const NOTIFICATIONS = {
// Push Notification Settings
BATCH_SIZE: 100, // Expo recommends batches of 100
DEFAULT_TTL_SECONDS: 86400, // 24 hours
DEFAULT_PRIORITY: 'high',
DEFAULT_SOUND: 'default',
DEFAULT_CHANNEL_ID: 'default',
EMERGENCY_CHANNEL_ID: 'emergency',
// Quiet Hours Defaults
DEFAULT_QUIET_START: '22:00',
DEFAULT_QUIET_END: '07:00',
// History
HISTORY_DEFAULT_LIMIT: 50,
HISTORY_DEFAULT_OFFSET: 0,
// Time Conversion
MINUTES_PER_HOUR: 60,
};
// ============ SERIAL VALIDATION ============
const SERIAL = {
// Serial Number Formats
MIN_LENGTH: 8,
PRODUCTION_LENGTH: 16,
PRODUCTION_PATTERN: /^WELLNUO-[A-Z0-9]{4}-[A-Z0-9]{4}$/,
LEGACY_PATTERN: /^[A-Z0-9\-]{8,}$/,
VALID_CHARS_PATTERN: /^[A-Z0-9\-]+$/,
};
// ============ EMAIL ============
const EMAIL = {
// Email Template Settings
MAX_CONTAINER_WIDTH: 600,
LOGO_FONT_SIZE: 24,
TITLE_FONT_SIZE: 20,
// OTP Email
OTP_CODE_FONT_SIZE: 32,
OTP_CODE_LETTER_SPACING: 6,
OTP_EXPIRY_TEXT_MINUTES: 10,
// Brevo Defaults
DEFAULT_SENDER_NAME: 'WellNuo',
DEFAULT_SENDER_EMAIL: 'noreply@wellnuo.com',
};
// ============ CRON JOBS ============
const CRON = {
// Subscription Sync
SYNC_CRON_SCHEDULE: '0 * * * *', // Every hour
};
// ============ STORAGE ============
const STORAGE = {
// Avatar Settings
AVATAR_FILENAME_PREFIX: 'user-',
AVATAR_FOLDER: 'avatars/users',
};
module.exports = {
SECURITY,
AUTH,
SERVER,
MQTT,
NOTIFICATIONS,
SERIAL,
EMAIL,
CRON,
STORAGE,
};

View File

@ -1,9 +1,11 @@
require('dotenv').config();
const { SECURITY, SERVER, CRON } = require('./config/constants');
// ============ SECURITY VALIDATION ============
// Validate JWT_SECRET at startup
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
console.error('JWT_SECRET must be at least 32 characters!');
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < SECURITY.JWT_MIN_SECRET_LENGTH) {
console.error(`JWT_SECRET must be at least ${SECURITY.JWT_MIN_SECRET_LENGTH} characters!`);
process.exit(1);
}
@ -29,10 +31,10 @@ const { syncAllSubscriptions } = require('./services/subscription-sync');
const mqttService = require('./services/mqtt');
const app = express();
const PORT = process.env.PORT || 3000;
const PORT = process.env.PORT || SERVER.DEFAULT_PORT;
// Trust proxy for correct IP when behind nginx
app.set('trust proxy', 1);
app.set('trust proxy', SERVER.TRUST_PROXY_LEVEL);
// ============ SECURITY ============
@ -73,8 +75,8 @@ app.use(cors({
// Rate Limiting - защита от DDoS
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 100, // максимум 100 запросов за 15 минут
windowMs: SECURITY.RATE_LIMIT.WINDOW_MS,
max: SECURITY.RATE_LIMIT.MAX_REQUESTS,
message: { error: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false
@ -82,8 +84,8 @@ const limiter = rateLimit({
// Лимит для auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30, // 30 попыток логина за 15 минут
windowMs: SECURITY.RATE_LIMIT.WINDOW_MS,
max: SECURITY.RATE_LIMIT.AUTH_MAX_REQUESTS,
message: { error: 'Too many login attempts, please try again later' },
// Пропускаем админов без лимита
skip: (req) => {
@ -103,8 +105,8 @@ app.use('/api/webhook/stripe', express.raw({ type: 'application/json' }));
// JSON body parser for other routes
// Increased limit for base64 avatar uploads
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(express.json({ limit: SERVER.JSON_BODY_LIMIT }));
app.use(express.urlencoded({ extended: true, limit: SERVER.JSON_BODY_LIMIT }));
// ============ ROUTES ============
@ -164,18 +166,18 @@ app.get('/api', (req, res) => {
// ============ CRON JOBS ============
// Sync subscriptions from Stripe every hour
cron.schedule('0 * * * *', async () => {
cron.schedule(CRON.SYNC_CRON_SCHEDULE, async () => {
console.log('[CRON] Running subscription sync...');
const result = await syncAllSubscriptions();
console.log('[CRON] Subscription sync result:', result);
});
// Run sync on startup (after 10 seconds to let everything initialize)
// Run sync on startup (after delay to let everything initialize)
setTimeout(async () => {
console.log('[STARTUP] Running initial subscription sync...');
const result = await syncAllSubscriptions();
console.log('[STARTUP] Initial sync result:', result);
}, 10000);
}, SERVER.INITIAL_SYNC_DELAY_MS);
// Manual sync endpoint (for admin)
app.post('/api/admin/sync-subscriptions', async (req, res) => {
@ -195,7 +197,7 @@ app.listen(PORT, () => {
setTimeout(async () => {
const deployments = await mqttService.subscribeToAllDeployments();
console.log(`[MQTT] Subscribed to ${deployments.length} deployments:`, deployments);
}, 3000);
}, SERVER.MQTT_SUBSCRIBE_DELAY_MS);
});
// Graceful shutdown

View File

@ -6,11 +6,12 @@ const rateLimit = require('express-rate-limit');
const { supabase } = require('../config/supabase');
const { sendOTPEmail } = require('../services/email');
const storage = require('../services/storage');
const { AUTH, SECURITY, STORAGE } = require('../config/constants');
// Rate limiter for OTP verification: 5 attempts per 15 minutes per email
// Rate limiter for OTP verification
const verifyOtpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
windowMs: AUTH.OTP_VERIFY_WINDOW_MS,
max: AUTH.OTP_VERIFY_MAX_ATTEMPTS,
keyGenerator: (req) => {
// Use email only - avoid IP-based limiting issues
const email = req.body?.email?.toLowerCase()?.trim();
@ -21,10 +22,10 @@ const verifyOtpLimiter = rateLimit({
legacyHeaders: false,
});
// Rate limiter for OTP request: 3 attempts per 15 minutes per email
// Rate limiter for OTP request
const requestOtpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3,
windowMs: AUTH.OTP_REQUEST_WINDOW_MS,
max: AUTH.OTP_REQUEST_MAX_ATTEMPTS,
keyGenerator: (req) => {
// Use email only - avoid IP-based limiting issues
const email = req.body?.email?.toLowerCase()?.trim();
@ -119,8 +120,8 @@ router.post('/request-otp', requestOtpLimiter, async (req, res) => {
}
// Генерируем 6-значный OTP код
const otpCode = crypto.randomInt(100000, 999999).toString();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 минут
const otpCode = crypto.randomInt(AUTH.OTP_CODE_MIN, AUTH.OTP_CODE_MAX).toString();
const expiresAt = new Date(Date.now() + AUTH.OTP_EXPIRY_MS);
// Удаляем старые неиспользованные коды для этого email
await supabase
@ -266,7 +267,7 @@ router.post('/verify-otp', verifyOtpLimiter, async (req, res) => {
email: user.email
},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
{ expiresIn: process.env.JWT_EXPIRES_IN || SECURITY.JWT_DEFAULT_EXPIRES_IN }
);
res.json({
@ -503,8 +504,8 @@ router.patch('/avatar', async (req, res) => {
}
// Upload new avatar to MinIO
const filename = `user-${userId}-${Date.now()}`;
const result = await storage.uploadBase64Image(avatar, 'avatars/users', filename);
const filename = `${STORAGE.AVATAR_FILENAME_PREFIX}${userId}-${Date.now()}`;
const result = await storage.uploadBase64Image(avatar, STORAGE.AVATAR_FOLDER, filename);
avatarUrl = result.url;
console.log('[AUTH] Avatar uploaded to MinIO:', avatarUrl);

View File

@ -14,15 +14,16 @@
const mqtt = require('mqtt');
const { pool } = require('../config/database');
const { sendPushNotifications: sendNotificationsWithSettings, NotificationType } = require('./notifications');
const { MQTT } = require('../config/constants');
// MQTT Configuration
const MQTT_BROKER = process.env.MQTT_BROKER || 'mqtt://mqtt.eluxnetworks.net:1883';
const MQTT_BROKER = process.env.MQTT_BROKER || `mqtt://mqtt.eluxnetworks.net:${MQTT.DEFAULT_PORT}`;
const MQTT_USER = process.env.MQTT_USER || process.env.LEGACY_API_USERNAME || 'robster';
const MQTT_PASSWORD = process.env.MQTT_PASSWORD || process.env.LEGACY_API_PASSWORD || 'rob2';
// Store for received alerts (in-memory, last 100)
// Store for received alerts (in-memory)
const alertsCache = [];
const MAX_ALERTS_CACHE = 100;
const MAX_ALERTS_CACHE = MQTT.MAX_ALERTS_CACHE;
// MQTT Client
let client = null;
@ -44,8 +45,8 @@ function init() {
username: MQTT_USER,
password: MQTT_PASSWORD,
clientId: `wellnuo-backend-${Date.now()}`,
reconnectPeriod: 5000, // Reconnect every 5 seconds
keepalive: 60,
reconnectPeriod: MQTT.RECONNECT_PERIOD_MS,
keepalive: MQTT.KEEPALIVE_SECONDS,
});
client.on('connect', () => {
@ -335,7 +336,7 @@ function subscribeToDeployments(deploymentIds) {
/**
* Get recent alerts from cache
*/
function getRecentAlerts(limit = 50, deploymentId = null) {
function getRecentAlerts(limit = MQTT.DEFAULT_ALERTS_LIMIT, deploymentId = null) {
let alerts = alertsCache;
if (deploymentId) {

View File

@ -8,6 +8,7 @@
*/
const { supabase } = require('../config/supabase');
const { NOTIFICATIONS } = require('../config/constants');
// Expo Push API endpoint
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
@ -51,9 +52,9 @@ function isInQuietHours(quietStart, quietEnd, timezone = 'UTC') {
const [currentHour, currentMin] = timeStr.split(':').map(Number);
// Convert to minutes since midnight for easier comparison
const currentMinutes = currentHour * 60 + currentMin;
const startMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
const currentMinutes = currentHour * NOTIFICATIONS.MINUTES_PER_HOUR + currentMin;
const startMinutes = startHour * NOTIFICATIONS.MINUTES_PER_HOUR + startMin;
const endMinutes = endHour * NOTIFICATIONS.MINUTES_PER_HOUR + endMin;
// Handle overnight quiet hours (e.g., 22:00 - 07:00)
if (startMinutes > endMinutes) {
@ -128,8 +129,8 @@ function shouldSendNotification(settings, notificationType) {
// Check quiet hours (except for emergency which returns early)
if (settings.quiet_hours_enabled) {
const quietStart = settings.quiet_hours_start || '22:00';
const quietEnd = settings.quiet_hours_end || '07:00';
const quietStart = settings.quiet_hours_start || NOTIFICATIONS.DEFAULT_QUIET_START;
const quietEnd = settings.quiet_hours_end || NOTIFICATIONS.DEFAULT_QUIET_END;
if (isInQuietHours(quietStart, quietEnd)) {
return { allowed: false, reason: 'quiet_hours' };
@ -297,11 +298,11 @@ async function sendPushNotifications({
body,
type = NotificationType.SYSTEM,
data = {},
sound = 'default',
channelId = 'default',
sound = NOTIFICATIONS.DEFAULT_SOUND,
channelId = NOTIFICATIONS.DEFAULT_CHANNEL_ID,
badge,
ttl = 86400, // 24 hours default
priority = 'high',
ttl = NOTIFICATIONS.DEFAULT_TTL_SECONDS,
priority = NOTIFICATIONS.DEFAULT_PRIORITY,
beneficiaryId = null
}) {
// Normalize userIds to array
@ -427,7 +428,7 @@ async function sendPushNotifications({
// Send all messages in batch
if (messagesToSend.length > 0) {
// Expo recommends batches of 100
const batchSize = 100;
const batchSize = NOTIFICATIONS.BATCH_SIZE;
const batches = [];
for (let i = 0; i < messagesToSend.length; i += batchSize) {
@ -547,8 +548,8 @@ async function notifyCaretakers(beneficiaryId, notification) {
*/
async function getNotificationHistory(userId, options = {}) {
const {
limit = 50,
offset = 0,
limit = NOTIFICATIONS.HISTORY_DEFAULT_LIMIT,
offset = NOTIFICATIONS.HISTORY_DEFAULT_OFFSET,
type,
status
} = options;

View File

@ -7,6 +7,8 @@
* - Legacy: 8+ alphanumeric characters
*/
const { SERIAL } = require('../config/constants');
// Demo serial numbers
const DEMO_SERIALS = new Set([
'DEMO-00000',
@ -52,7 +54,7 @@ function validateSerial(serial) {
// Production format: WELLNUO-XXXX-XXXX
// Must be exactly 16 characters with hyphens at positions 7 and 12
if (/^WELLNUO-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(normalized)) {
if (SERIAL.PRODUCTION_PATTERN.test(normalized)) {
return {
isValid: true,
format: 'production',
@ -61,7 +63,7 @@ function validateSerial(serial) {
}
// Legacy format: minimum 8 alphanumeric characters (no special characters except hyphens)
if (/^[A-Z0-9\-]{8,}$/.test(normalized)) {
if (SERIAL.LEGACY_PATTERN.test(normalized)) {
return {
isValid: true,
format: 'legacy',
@ -72,9 +74,9 @@ function validateSerial(serial) {
// Invalid format
let error = 'Invalid serial number format';
if (normalized.length < 8) {
error = 'Serial number must be at least 8 characters';
} else if (!/^[A-Z0-9\-]+$/.test(normalized)) {
if (normalized.length < SERIAL.MIN_LENGTH) {
error = `Serial number must be at least ${SERIAL.MIN_LENGTH} characters`;
} else if (!SERIAL.VALID_CHARS_PATTERN.test(normalized)) {
error = 'Serial number can only contain letters, numbers, and hyphens';
}