- Monitoring badge: equipment active + subscription active - Get kit badge: user hasn't ordered equipment yet - Equipment status badges: ordered, shipped, delivered - No subscription warning when equipment works but no sub - Stripe subscription caching in backend (hourly sync) - BeneficiaryMenu with edit/share/archive/delete actions
496 lines
14 KiB
JavaScript
496 lines
14 KiB
JavaScript
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;
|