Sergei bbb60a9e3f 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>
2026-01-29 11:52:47 -08:00

560 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
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
const verifyOtpLimiter = rateLimit({
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();
return email || 'unknown';
},
message: { error: 'Too many verification attempts. Please try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
});
// Rate limiter for OTP request
const requestOtpLimiter = rateLimit({
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();
return email || 'unknown';
},
message: { error: 'Too many OTP requests. Please try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
});
/**
* POST /api/auth/check-email
* Проверяет существует ли пользователь с данным email
* Возвращает exists: true/false и имя если существует
*/
router.post('/check-email', async (req, res) => {
try {
const { email } = req.body;
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid email is required' });
}
const normalizedEmail = email.toLowerCase().trim();
// Проверяем существует ли пользователь
const { data: existingUser } = await supabase
.from('users')
.select('id, email, first_name')
.eq('email', normalizedEmail)
.single();
if (existingUser) {
res.json({
exists: true,
name: existingUser.first_name || null
});
} else {
res.json({
exists: false
});
}
} catch (error) {
console.error('Check email error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/auth/request-otp
* Отправляет OTP код на email
* Если пользователя нет - создаёт нового
* Rate limited: 3 requests per 15 minutes per email/IP
*/
router.post('/request-otp', requestOtpLimiter, async (req, res) => {
try {
const { email } = req.body;
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid email is required' });
}
const normalizedEmail = email.toLowerCase().trim();
// Проверяем существует ли пользователь в таблице users
const { data: existingUser } = await supabase
.from('users')
.select('id, email, first_name')
.eq('email', normalizedEmail)
.single();
let isNewUser = !existingUser;
let userId = existingUser?.id;
// Если пользователя нет - создаём
if (isNewUser) {
const { data: newUser, error: createError } = await supabase
.from('users')
.insert({
email: normalizedEmail
})
.select()
.single();
if (createError) {
console.error('Create user error:', createError);
return res.status(500).json({ error: 'Failed to create user' });
}
userId = newUser.id;
}
// Генерируем 6-значный OTP код
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
.from('otp_codes')
.delete()
.eq('email', normalizedEmail)
.is('used_at', null);
// Сохраняем новый код
const { error: otpError } = await supabase
.from('otp_codes')
.insert({
email: normalizedEmail,
code: otpCode,
expires_at: expiresAt.toISOString()
});
if (otpError) {
console.error('OTP save error:', otpError);
return res.status(500).json({ error: 'Failed to generate OTP' });
}
// Отправляем email
console.log(`[OTP] Sending code ${otpCode} to ${normalizedEmail}`);
const emailSent = await sendOTPEmail(normalizedEmail, otpCode, existingUser?.first_name);
console.log(`[OTP] Email sent result: ${emailSent}`);
if (!emailSent) {
return res.status(500).json({ error: 'Failed to send email' });
}
res.json({
success: true,
message: 'OTP sent to email',
isNewUser
});
} catch (error) {
console.error('Request OTP error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/auth/verify-otp
* Проверяет OTP код и возвращает JWT токен
* Rate limited: 5 attempts per 15 minutes per email/IP
*/
router.post('/verify-otp', verifyOtpLimiter, async (req, res) => {
try {
const { email, code } = req.body;
if (!email || !code) {
return res.status(400).json({ error: 'Email and code are required' });
}
const normalizedEmail = email.toLowerCase().trim();
// Ищем валидный OTP (used_at IS NULL означает неиспользованный)
const { data: otpRecord, error: otpError } = await supabase
.from('otp_codes')
.select('*')
.eq('email', normalizedEmail)
.eq('code', code)
.is('used_at', null)
.gt('expires_at', new Date().toISOString())
.single();
if (otpError || !otpRecord) {
return res.status(401).json({ error: 'Invalid or expired code' });
}
// Помечаем код как использованный
await supabase
.from('otp_codes')
.update({ used_at: new Date().toISOString() })
.eq('id', otpRecord.id);
// Получаем пользователя из таблицы users
const { data: user, error: userError } = await supabase
.from('users')
.select('*')
.eq('email', normalizedEmail)
.single();
if (userError || !user) {
return res.status(404).json({ error: 'User not found' });
}
// Обновляем last_login_at
await supabase
.from('users')
.update({ last_login_at: new Date().toISOString() })
.eq('id', user.id);
// Получаем beneficiaries через user_access
const { data: accessRecords } = await supabase
.from('user_access')
.select(`
beneficiary_id,
role,
granted_at,
custom_name,
beneficiaries:beneficiary_id (
id,
name,
phone,
address,
avatar_url,
equipment_status,
created_at
)
`)
.eq('accessor_id', user.id);
// Форматируем beneficiaries с displayName
const beneficiaries = (accessRecords || []).map(record => {
const customName = record.custom_name || null;
const originalName = record.beneficiaries?.name || null;
const displayName = customName || originalName;
return {
id: record.beneficiary_id,
role: record.role,
grantedAt: record.granted_at,
customName: customName,
displayName: displayName,
originalName: originalName,
name: record.beneficiaries?.name || null,
phone: record.beneficiaries?.phone || null,
address: record.beneficiaries?.address || null,
avatarUrl: record.beneficiaries?.avatar_url || null,
equipmentStatus: record.beneficiaries?.equipment_status || 'none',
hasDevices: record.beneficiaries?.equipment_status === 'active' || record.beneficiaries?.equipment_status === 'demo',
createdAt: record.beneficiaries?.created_at || null
};
});
// Генерируем JWT токен
const token = jwt.sign(
{
userId: user.id,
email: user.email
},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || SECURITY.JWT_DEFAULT_EXPIRES_IN }
);
res.json({
success: true,
token,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
role: user.role || 'user',
avatarUrl: user.avatar_url || null
},
beneficiaries
});
} catch (error) {
console.error('Verify OTP error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/auth/me
* Возвращает текущего пользователя по JWT токену
*/
router.get('/me', async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
// Получаем пользователя из таблицы users
const { data: user, error } = await supabase
.from('users')
.select('*')
.eq('id', decoded.userId)
.single();
if (error || !user) {
return res.status(404).json({ error: 'User not found' });
}
// Получаем beneficiaries через user_access
const { data: accessRecords } = await supabase
.from('user_access')
.select(`
beneficiary_id,
role,
granted_at,
custom_name,
beneficiaries:beneficiary_id (
id,
name,
phone,
address,
avatar_url,
equipment_status,
created_at
)
`)
.eq('accessor_id', user.id);
// Форматируем beneficiaries с displayName
const beneficiaries = (accessRecords || []).map(record => {
const customName = record.custom_name || null;
const originalName = record.beneficiaries?.name || null;
const displayName = customName || originalName;
return {
id: record.beneficiary_id,
role: record.role,
grantedAt: record.granted_at,
customName: customName,
displayName: displayName,
originalName: originalName,
name: record.beneficiaries?.name || null,
phone: record.beneficiaries?.phone || null,
address: record.beneficiaries?.address || null,
avatarUrl: record.beneficiaries?.avatar_url || null,
equipmentStatus: record.beneficiaries?.equipment_status || 'none',
hasDevices: record.beneficiaries?.equipment_status === 'active' || record.beneficiaries?.equipment_status === 'demo',
createdAt: record.beneficiaries?.created_at || null
};
});
res.json({
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
role: user.role || 'user',
avatarUrl: user.avatar_url || null
},
beneficiaries
});
} catch (error) {
console.error('Get me error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* PATCH /api/auth/profile
* Обновляет профиль пользователя
*/
router.patch('/profile', async (req, res) => {
try {
console.log('[AUTH] PATCH /profile - body:', JSON.stringify(req.body));
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
console.log('[AUTH] PATCH /profile - userId:', decoded.userId);
const { firstName, lastName, phone, addressStreet, addressCity, addressZip, addressState, addressCountry } = req.body;
const updateData = {
updated_at: new Date().toISOString()
};
if (firstName !== undefined) updateData.first_name = firstName;
if (lastName !== undefined) updateData.last_name = lastName;
if (phone !== undefined) updateData.phone = phone;
if (addressStreet !== undefined) updateData.address_street = addressStreet;
if (addressCity !== undefined) updateData.address_city = addressCity;
if (addressZip !== undefined) updateData.address_zip = addressZip;
if (addressState !== undefined) updateData.address_state = addressState;
if (addressCountry !== undefined) updateData.address_country = addressCountry;
const { data: user, error } = await supabase
.from('users')
.update(updateData)
.eq('id', decoded.userId)
.select()
.single();
if (error) {
console.error('[AUTH] PATCH /profile - DB error:', error);
return res.status(500).json({ error: 'Failed to update profile' });
}
console.log('[AUTH] PATCH /profile - success! User:', user.id, 'firstName:', user.first_name);
res.json({
success: true,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
addressStreet: user.address_street,
addressCity: user.address_city,
addressZip: user.address_zip,
addressState: user.address_state,
addressCountry: user.address_country
}
});
} catch (error) {
console.error('Update profile error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* PATCH /api/auth/avatar
* Upload/update user profile avatar
* - Uploads to MinIO if configured
* - Falls back to base64 in DB if MinIO not available
*/
router.patch('/avatar', async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const userId = decoded.userId;
const { avatar } = req.body; // base64 string or null to remove
console.log('[AUTH] Avatar update:', { userId, hasAvatar: !!avatar });
// Validate base64 if provided
if (avatar && !avatar.startsWith('data:image/')) {
return res.status(400).json({ error: 'Invalid image format. Must be base64 data URI' });
}
let avatarUrl = null;
if (avatar) {
// Try to upload to MinIO
if (storage.isConfigured()) {
try {
// Get current avatar to delete old file
const { data: current } = await supabase
.from('users')
.select('avatar_url')
.eq('id', userId)
.single();
// Delete old avatar from MinIO if exists
if (current?.avatar_url && current.avatar_url.includes('minio')) {
const oldKey = storage.extractKeyFromUrl(current.avatar_url);
if (oldKey) {
try {
await storage.deleteFile(oldKey);
} catch (e) {
console.warn('[AUTH] Failed to delete old avatar:', e.message);
}
}
}
// Upload new avatar to MinIO
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);
} catch (uploadError) {
console.error('[AUTH] MinIO upload failed, falling back to DB:', uploadError.message);
// Fallback: store base64 in DB
avatarUrl = avatar;
}
} else {
// MinIO not configured - store base64 in DB
console.log('[AUTH] MinIO not configured, storing base64 in DB');
avatarUrl = avatar;
}
}
// Update avatar_url in users table
const { data: user, error } = await supabase
.from('users')
.update({
avatar_url: avatarUrl,
updated_at: new Date().toISOString()
})
.eq('id', userId)
.select('id, email, first_name, last_name, avatar_url')
.single();
if (error) {
console.error('[AUTH] Avatar update error:', error);
return res.status(500).json({ error: 'Failed to update avatar' });
}
console.log('[AUTH] Avatar updated:', { userId, avatarUrl: user.avatar_url?.substring(0, 50) });
res.json({
success: true,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
avatarUrl: user.avatar_url
}
});
} catch (error) {
console.error('[AUTH] Avatar error:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;