Files
hotel777-manager/server.js
2026-05-04 21:49:42 +05:00

337 lines
13 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.
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}`);
});