const express = require('express'); const router = express.Router(); const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const { supabase } = require('../config/supabase'); const { sendOTPEmail } = require('../services/email'); const storage = require('../services/storage'); /** * 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 * Если пользователя нет - создаёт нового */ router.post('/request-otp', 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(100000, 999999).toString(); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 минут // Удаляем старые неиспользованные коды для этого 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 токен */ router.post('/verify-otp', 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, beneficiaries:beneficiary_id ( id, email, first_name, last_name, phone, address_street, address_city, address_zip, address_state, address_country ) `) .eq('accessor_id', user.id); // Форматируем beneficiaries const beneficiaries = (accessRecords || []).map(record => ({ id: record.beneficiary_id, role: record.role, grantedAt: record.granted_at, ...record.beneficiaries })); // Генерируем JWT токен const token = jwt.sign( { userId: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || '7d' } ); 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, beneficiaries:beneficiary_id ( id, email, first_name, last_name, phone, address_street, address_city, address_zip, address_state, address_country ) `) .eq('accessor_id', user.id); // Форматируем beneficiaries const beneficiaries = (accessRecords || []).map(record => ({ id: record.beneficiary_id, role: record.role, grantedAt: record.granted_at, ...record.beneficiaries })); 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 { 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 { 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) { return res.status(500).json({ error: 'Failed to update profile' }); } 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 = `user-${userId}-${Date.now()}`; const result = await storage.uploadBase64Image(avatar, 'avatars/users', 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;