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