require('dotenv').config(); const express = require('express'); const path = require('path'); const bcrypt = require('bcrypt'); const cron = require('node-cron'); const { db, normalizePhone, logAction, ensureSchema } = require('./db'); const { sessionMiddleware, csrfProtection, injectCsrfToken, ensureAdmin, requireAdmin } = require('./auth'); const { syncBookings } = require('./sync'); const { notifyBookingUpdate, verifyEmailConnection } = require('./mailer'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(express.json()); app.use(sessionMiddleware); // Ensure schema on startup ensureSchema(); app.use(injectCsrfToken); // Статика из public app.use(express.static(path.join(__dirname, 'public'))); // CSRF token endpoint app.get('/api/csrf-token', (req, res) => { res.json({ csrfToken: req.session.csrfToken }); }); // === Инициализация === // Асинхронное создание/обновление администратора из .env (async () => { await ensureAdmin(); })(); // Проверка работоспособности SMTP при старте (отключит уведомления при ошибке) verifyEmailConnection().catch(err => { console.error('Ошибка при проверке SMTP:', err); }); // === API === // Вход администратора (с хэшированием и сохранением adminId) app.post('/api/login', csrfProtection, async (req, res) => { try { const { login, password } = req.body; if (!login || !password) { return res.status(400).json({ error: 'Логин и пароль обязательны' }); } const admin = db.prepare('SELECT id, login, password FROM admins WHERE login = ?').get(login); if (admin && await bcrypt.compare(password, admin.password)) { req.session.isAdmin = true; req.session.adminId = admin.id; req.session.csrfToken = req.session.csrfToken; res.json({ success: true, csrfToken: req.session.csrfToken }); } else { res.status(401).json({ error: 'Неверный логин или пароль' }); } } catch (err) { console.error('Ошибка входа:', err); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Выход app.post('/api/logout', csrfProtection, (req, res) => { req.session.destroy(); res.json({ success: true }); }); // Проверка сессии app.get('/api/me', (req, res) => { res.json({ isAdmin: !!req.session.isAdmin }); }); // --- Управление администраторами (CRUD) --- // Получить список всех администраторов (только id и login) app.get('/api/admins', requireAdmin, (req, res) => { try { const admins = db.prepare('SELECT id, login FROM admins ORDER BY id').all(); res.json(admins); } catch (err) { console.error('Ошибка получения администраторов:', err); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Добавить нового администратора app.post('/api/admins', requireAdmin, csrfProtection, async (req, res) => { try { const { login, password } = req.body; if (!login || !password) { return res.status(400).json({ error: 'Логин и пароль обязательны' }); } if (login.length < 3 || password.length < 6) { return res.status(400).json({ error: 'Логин минимум 3 символа, пароль минимум 6 символов' }); } const existing = db.prepare('SELECT id FROM admins WHERE login = ?').get(login); if (existing) { return res.status(409).json({ error: 'Администратор с таким логином уже существует' }); } const saltRounds = 10; const hashed = await bcrypt.hash(password, saltRounds); const stmt = db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)'); const info = stmt.run(login, hashed); res.status(201).json({ id: info.lastInsertRowid, login }); } catch (err) { console.error('Ошибка создания администратора:', err); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Изменить пароль администратора app.put('/api/admins/:id', requireAdmin, csrfProtection, async (req, res) => { try { const { id } = req.params; const { password } = req.body; if (!password) { return res.status(400).json({ error: 'Новый пароль обязателен' }); } if (password.length < 6) { return res.status(400).json({ error: 'Пароль минимум 6 символов' }); } const admin = db.prepare('SELECT id, login FROM admins WHERE id = ?').get(id); if (!admin) { return res.status(404).json({ error: 'Администратор не найден' }); } const saltRounds = 10; const hashed = await bcrypt.hash(password, saltRounds); db.prepare('UPDATE admins SET password = ? WHERE id = ?').run(hashed, id); res.json({ id: admin.id, login: admin.login }); } catch (err) { console.error('Ошибка обновления пароля:', err); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Удалить администратора app.delete('/api/admins/:id', requireAdmin, csrfProtection, (req, res) => { try { const { id } = req.params; const count = db.prepare('SELECT COUNT(*) as cnt FROM admins').get().cnt; if (count <= 1) { return res.status(400).json({ error: 'Нельзя удалить последнего администратора' }); } if (req.session.adminId && req.session.adminId == id) { return res.status(400).json({ error: 'Нельзя удалить свою учётную запись' }); } const stmt = db.prepare('DELETE FROM admins WHERE id = ?'); const result = stmt.run(id); if (result.changes === 0) { return res.status(404).json({ error: 'Администратор не найден' }); } res.json({ success: true }); } catch (err) { console.error('Ошибка удаления администратора:', err); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // --- Заявки (bookings) --- // Список заявок с фильтрами (обогащён данными клиента) app.get('/api/bookings', requireAdmin, (req, res) => { try { const { status, search, client_id } = req.query; let query = `SELECT b.*, COALESCE(u.name, b.name) AS client_name, COALESCE(u.phone, b.phone_raw) AS client_phone, COALESCE(u.status, 'Bronze') AS client_status FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE 1=1`; const params = []; if (status) { query += ' AND b.status = ?'; params.push(status); } if (client_id) { query += ' AND b.user_id = ?'; params.push(client_id); } if (search) { query += ' AND (b.name LIKE ? OR b.phone_raw LIKE ? OR b.comments LIKE ? OR u.name LIKE ?)'; params.push(`%${search}%`, `%${search}%`, `%${search}%`, `%${search}%`); } query += ' ORDER BY b.created_at DESC'; const bookings = db.prepare(query).all(...params); res.json(bookings); } catch (err) { console.error('Ошибка получения заявок:', err); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Детали заявки app.get('/api/bookings/:id', requireAdmin, (req, res) => { try { const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id); if (!booking) return res.status(404).json({ error: 'Заявка не найдена' }); res.json(booking); } catch (err) { console.error('Ошибка получения заявки:', err); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Обновление заявки (статус, комментарий, перепривязка клиента) app.put('/api/bookings/:id', requireAdmin, csrfProtection, (req, res) => { try { const { id } = req.params; const { status, comments, phone } = req.body; const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); if (!booking) return res.status(404).json({ error: 'Заявка не найдена' }); const changes = {}; if (status && status !== booking.status) { const allowed = ['Новая', 'В работе', 'Подтверждена', 'Заселение', 'Завершена', 'Отменена']; if (!allowed.includes(status)) { return res.status(400).json({ error: 'Недопустимый статус' }); } db.prepare('UPDATE bookings SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, id); changes.status = { from: booking.status, to: status }; } if (comments !== undefined && comments !== booking.comments) { db.prepare('UPDATE bookings SET comments = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(comments, id); changes.comments = { from: booking.comments, to: comments }; } if (phone) { const normPhone = normalizePhone(phone); const user = db.prepare('SELECT id FROM users WHERE phone = ?').get(normPhone); if (!user) { return res.status(400).json({ error: 'Карточка клиента с таким телефоном не найдена' }); } if (user.id !== booking.user_id) { db.prepare('UPDATE bookings SET user_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(user.id, id); changes.user_id = { from: booking.user_id, to: user.id }; } } if (Object.keys(changes).length > 0) { logAction(`Изменение заявки #${id}`, changes); const updated = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); notifyBookingUpdate(updated, changes); } const updatedBooking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); res.json(updatedBooking); } catch (err) { console.error('Ошибка обновления заявки:', err); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // --- Карточки клиентов --- // Список клиентов app.get('/api/clients', requireAdmin, (req, res) => { try { const { search } = req.query; let query = 'SELECT * FROM users WHERE 1=1'; const params = []; if (search) { query += ' AND (phone LIKE ? OR name LIKE ?)'; params.push(`%${search}%`, `%${search}%`); } query += ' ORDER BY name ASC'; const clients = db.prepare(query).all(...params); res.json(clients); } catch (err) { console.error('Ошибка получения клиентов:', err); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Профиль клиента с его заявками app.get('/api/clients/:id', requireAdmin, (req, res) => { try { const client = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); if (!client) return res.status(404).json({ error: 'Клиент не найден' }); const bookings = db.prepare(` SELECT * FROM bookings WHERE user_id = ? ORDER BY created_at DESC, status ASC `).all(req.params.id); res.json({ client, bookings }); } catch (err) { console.error('Ошибка получения профиля клиента:', err); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Изменение имени клиента app.put('/api/clients/:id', requireAdmin, csrfProtection, (req, res) => { const { id } = req.params; const { name } = req.body; if (name === undefined) return res.status(400).json({ error: 'Имя обязательно' }); const client = db.prepare('SELECT id FROM users WHERE id = ?').get(id); if (!client) return res.status(404).json({ error: 'Клиент не найден' }); db.prepare('UPDATE users SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(name, id); const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(id); res.json(updated); }); // --- Синхронизация --- // Ручной запуск синхронизации app.post('/api/admin/sync', requireAdmin, csrfProtection, async (req, res) => { try { await syncBookings(); res.json({ success: true, message: 'Синхронизация запущена' }); } catch (err) { console.error('Ошибка синхронизации:', err); res.status(500).json({ error: 'Ошибка синхронизации' }); } }); // Планировщик синхронизации каждые 5 минут cron.schedule('*/5 * * * *', () => { console.log('Автосинхронизация...'); syncBookings().catch(console.error); }); // Запуск сервера app.listen(PORT, () => { console.log(`Сервис "${process.env.SERVICE_NAME}" запущен на порту ${PORT}`); console.log(`Ссылка: ${process.env.SERVICE_URL}`); });