From bbb60a9e3f3f903450a63167c3f8fcd1fd77d259 Mon Sep 17 00:00:00 2001 From: Sergei Date: Thu, 29 Jan 2026 11:52:47 -0800 Subject: [PATCH] Extract magic numbers to centralized constants module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/config/__tests__/constants.test.js | 206 ++++++++++++++++++ backend/src/config/constants.js | 152 +++++++++++++ backend/src/index.js | 30 +-- backend/src/routes/auth.js | 23 +- backend/src/services/mqtt.js | 13 +- backend/src/services/notifications.js | 25 ++- backend/src/utils/serialValidation.js | 12 +- 7 files changed, 413 insertions(+), 48 deletions(-) create mode 100644 backend/src/config/__tests__/constants.test.js create mode 100644 backend/src/config/constants.js diff --git a/backend/src/config/__tests__/constants.test.js b/backend/src/config/__tests__/constants.test.js new file mode 100644 index 0000000..4fd9d0e --- /dev/null +++ b/backend/src/config/__tests__/constants.test.js @@ -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); + }); + }); +}); diff --git a/backend/src/config/constants.js b/backend/src/config/constants.js new file mode 100644 index 0000000..1a12d6f --- /dev/null +++ b/backend/src/config/constants.js @@ -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, +}; diff --git a/backend/src/index.js b/backend/src/index.js index e90777f..69a2b8d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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 diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 474f748..132c31c 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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); diff --git a/backend/src/services/mqtt.js b/backend/src/services/mqtt.js index e95928b..4aa1d0a 100644 --- a/backend/src/services/mqtt.js +++ b/backend/src/services/mqtt.js @@ -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) { diff --git a/backend/src/services/notifications.js b/backend/src/services/notifications.js index c30a660..09188c4 100644 --- a/backend/src/services/notifications.js +++ b/backend/src/services/notifications.js @@ -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; diff --git a/backend/src/utils/serialValidation.js b/backend/src/utils/serialValidation.js index 7c3eefd..78b5663 100644 --- a/backend/src/utils/serialValidation.js +++ b/backend/src/utils/serialValidation.js @@ -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'; }