Sergei 657737e5a4 Add status badges for beneficiaries list
- 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
2026-01-09 19:49:07 -08:00

496 lines
14 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 { 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;