This commit is contained in:
2026-05-03 16:36:18 +05:00
parent f42a83750c
commit 8d98f92018
17 changed files with 2608 additions and 0 deletions

10
.gitignore vendored
View File

@@ -1,3 +1,13 @@
promt
data
.env
# ---> Node
# Logs
logs

35
auth.js Normal file
View File

@@ -0,0 +1,35 @@
const session = require('express-session');
const { db } = require('./db');
const sessionMiddleware = session({
secret: process.env.SESSION_SECRET || 'hotel-secret-key-change-me',
resave: false,
saveUninitialized: false,
cookie: { secure: false, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 }
});
function ensureAdmin() {
const login = process.env.ADMIN_LOGIN || 'admin';
const password = process.env.ADMIN_PASSWORD || 'admin';
// Таблица admins уже создана в db.js, можно сразу искать
const admin = db.prepare('SELECT id FROM admins WHERE login = ?').get(login);
if (admin) {
// Обновить пароль при каждом старте
db.prepare('UPDATE admins SET password = ? WHERE login = ?').run(password, login);
console.log(`Пароль администратора "${login}" обновлён`);
} else {
// Создать запись
db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)').run(login, password);
console.log(`Администратор "${login}" создан`);
}
}
function requireAdmin(req, res, next) {
if (req.session && req.session.isAdmin) {
return next();
}
res.status(401).json({ error: 'Не авторизован' });
}
module.exports = { sessionMiddleware, ensureAdmin, requireAdmin };

70
db.js Normal file
View File

@@ -0,0 +1,70 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir);
const db = new Database(path.join(dataDir, 'database.sqlite'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
-- Таблица администраторов (логин/пароль)
CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
);
-- Карточки клиентов
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT UNIQUE NOT NULL,
name TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Заявки на заселение
CREATE TABLE IF NOT EXISTS bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id INTEGER UNIQUE,
user_id INTEGER,
name TEXT,
phone_raw TEXT,
adults INTEGER DEFAULT 1,
children INTEGER DEFAULT 0,
checkin_date TEXT,
checkout_date TEXT,
status TEXT DEFAULT 'Новая',
comments TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Журнал действий
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
function normalizePhone(phone) {
let digits = phone.replace(/\D/g, '');
if (/^[78]\d{10}$/.test(digits)) {
digits = '7' + digits.slice(1);
}
return digits;
}
function logAction(action, details = {}) {
const stmt = db.prepare('INSERT INTO audit_log (action, details) VALUES (?, ?)');
stmt.run(action, JSON.stringify(details));
}
module.exports = { db, normalizePhone, logAction };

52
mailer.js Normal file
View File

@@ -0,0 +1,52 @@
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
async function sendNotification(emails, subject, text) {
if (!emails) return;
const recipients = emails.split(',').map(e => e.trim()).filter(Boolean);
if (recipients.length === 0) return;
try {
await transporter.sendMail({
from: process.env.SMTP_USER,
to: recipients.join(','),
subject,
text
});
console.log(`Уведомление отправлено на ${recipients.join(', ')}`);
} catch (err) {
console.error('Ошибка отправки письма:', err);
}
}
async function notifyNewBooking(booking) {
const subject = `Новая заявка №${booking.external_id} (${process.env.SERVICE_NAME})`;
const text = `Поступила новая заявка на заселение.\n
Имя: ${booking.name}
Телефон: ${booking.phone_raw}
Даты: с ${booking.checkin_date} по ${booking.checkout_date}
Взрослых: ${booking.adults}, детей: ${booking.children}
Ссылка: ${process.env.SERVICE_URL}/admin/bookings`;
await sendNotification(process.env.EMAIL_NOTIFY_NEW, subject, text);
}
async function notifyBookingUpdate(booking, changes) {
const subject = `Изменение заявки №${booking.external_id} (${process.env.SERVICE_NAME})`;
const text = `Заявка №${booking.external_id} была изменена.\n
Новый статус: ${booking.status}
Комментарий: ${booking.comments || 'нет'}
Изменения: ${JSON.stringify(changes, null, 2)}
Ссылка: ${process.env.SERVICE_URL}/admin/bookings`;
await sendNotification(process.env.EMAIL_NOTIFY_UPDATE, subject, text);
}
module.exports = { notifyNewBooking, notifyBookingUpdate };

1771
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"dependencies": {
"axios": "^1.16.0",
"better-sqlite3": "^12.9.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-session": "^1.19.0",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.7",
"sqlite3": "^6.0.1"
}
}

33
public/admin.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="ru" data-title="Панель управления" data-description="Администрирование заявок гостиницы">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header></header>
<main>
<h1>Панель управления</h1>
<p>Сервис: <span id="serviceName"></span></p>
<button id="syncBtn">Запустить синхронизацию заявок</button>
<p id="syncStatus"></p>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
// Проверка авторизации
fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html';
});
document.getElementById('syncBtn').addEventListener('click', async () => {
const status = document.getElementById('syncStatus');
status.textContent = 'Синхронизация...';
const res = await fetch('/api/admin/sync', { method: 'POST' });
if (res.ok) status.textContent = 'Синхронизация завершена';
else status.textContent = 'Ошибка синхронизации';
});
</script>
</body>
</html>

155
public/bookings.html Normal file
View File

@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="ru" data-title="Заявки на заселение" data-description="Управление заявками гостиницы">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header></header>
<main>
<h1>Заявки на заселение</h1>
<div>
<label>Фильтр по статусу:
<select id="statusFilter">
<option value="">Все</option>
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Подтверждена">Подтверждена</option>
<option value="Заселение">Заселение</option>
<option value="Завершена">Завершена</option>
<option value="Отменена">Отменена</option>
</select>
</label>
<input type="text" id="searchInput" placeholder="Поиск по имени, телефону или комментарию">
<button id="searchBtn">Найти</button>
</div>
<table id="bookingsTable">
<thead>
<tr>
<th>ID внеш.</th>
<th>Имя</th>
<th>Телефон</th>
<th>Даты</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
<!-- Модальное окно редактирования -->
<div id="editModal" class="modal" style="display:none;">
<div class="modal-content">
<h2>Редактировать заявку</h2>
<form id="editForm">
<input type="hidden" id="editId">
<label>Статус</label>
<select id="editStatus">
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Подтверждена">Подтверждена</option>
<option value="Заселение">Заселение</option>
<option value="Завершена">Завершена</option>
<option value="Отменена">Отменена</option>
</select>
<label>Комментарий</label>
<textarea id="editComments" rows="3"></textarea>
<label>Переназначить на карточку клиента (по телефону)</label>
<input type="text" id="editPhone" placeholder="Телефон (любой формат)">
<button type="submit">Сохранить</button>
<button type="button" id="closeModal">Отмена</button>
</form>
</div>
</div>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
// Проверка авторизации
fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html';
});
const tableBody = document.querySelector('#bookingsTable tbody');
const modal = document.getElementById('editModal');
let currentBookings = [];
async function loadBookings() {
const status = document.getElementById('statusFilter').value;
const search = document.getElementById('searchInput').value;
let url = '/api/bookings?';
if (status) url += `status=${encodeURIComponent(status)}&`;
if (search) url += `search=${encodeURIComponent(search)}&`;
const res = await fetch(url);
currentBookings = await res.json();
renderTable();
}
function renderTable() {
tableBody.innerHTML = '';
currentBookings.forEach(b => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${b.external_id || '-'}</td>
<td>${b.name}</td>
<td>${b.phone_raw}</td>
<td>${b.checkin_date} ${b.checkout_date}</td>
<td>${b.status}</td>
<td><button class="editBtn" data-id="${b.id}">Изменить</button></td>
`;
tableBody.appendChild(row);
});
}
document.getElementById('searchBtn').addEventListener('click', loadBookings);
document.getElementById('statusFilter').addEventListener('change', loadBookings);
// Редактирование
document.addEventListener('click', (e) => {
if (e.target.classList.contains('editBtn')) {
const id = e.target.dataset.id;
const booking = currentBookings.find(b => b.id == id);
if (!booking) return;
document.getElementById('editId').value = booking.id;
document.getElementById('editStatus').value = booking.status;
document.getElementById('editComments').value = booking.comments || '';
document.getElementById('editPhone').value = '';
modal.style.display = 'flex';
}
});
document.getElementById('closeModal').addEventListener('click', () => {
modal.style.display = 'none';
});
document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('editId').value;
const status = document.getElementById('editStatus').value;
const comments = document.getElementById('editComments').value;
const phone = document.getElementById('editPhone').value.trim();
const body = {};
if (status) body.status = status;
body.comments = comments;
if (phone) body.phone = phone;
const res = await fetch(`/api/bookings/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
modal.style.display = 'none';
loadBookings();
} else {
alert('Ошибка сохранения');
}
});
// Первоначальная загрузка
loadBookings();
</script>
</body>
</html>

65
public/client.html Normal file
View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="ru" data-title="Карточка клиента" data-description="Профиль клиента и его заявки">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header></header>
<main>
<h1>Профиль клиента</h1>
<div id="clientInfo"></div>
<h2>Заявки (сортировка: сначала новые, потом по статусу А-Я)</h2>
<table id="clientBookingsTable">
<thead>
<tr><th>ID внеш.</th><th>Имя</th><th>Даты</th><th>Статус</th><th>Комментарий</th></tr>
</thead>
<tbody></tbody>
</table>
<p><a href="clients.html">← Назад к списку</a></p>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html';
});
const params = new URLSearchParams(window.location.search);
const clientId = params.get('id');
if (!clientId) {
document.body.innerHTML = '<h1>Не указан ID клиента</h1>';
throw new Error('No id');
}
async function loadClient() {
const res = await fetch(`/api/clients/${clientId}`);
if (!res.ok) {
document.getElementById('clientInfo').innerHTML = '<p>Клиент не найден</p>';
return;
}
const data = await res.json();
document.getElementById('clientInfo').innerHTML = `
<p><strong>Телефон:</strong> ${data.client.phone}</p>
<p><strong>Имя:</strong> ${data.client.name || 'не указано'}</p>
`;
const tbody = document.querySelector('#clientBookingsTable tbody');
tbody.innerHTML = '';
data.bookings.forEach(b => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${b.external_id || '-'}</td>
<td>${b.name}</td>
<td>${b.checkin_date} ${b.checkout_date}</td>
<td>${b.status}</td>
<td>${b.comments || ''}</td>
`;
tbody.appendChild(tr);
});
}
loadClient();
</script>
</body>
</html>

53
public/clients.html Normal file
View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="ru" data-title="Клиенты" data-description="Список клиентов гостиницы">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header></header>
<main>
<h1>Клиенты</h1>
<input type="text" id="searchClient" placeholder="Поиск по имени или телефону">
<button id="searchClientBtn">Найти</button>
<table id="clientsTable">
<thead>
<tr><th>ID</th><th>Телефон</th><th>Имя</th><th>Действия</th></tr>
</thead>
<tbody></tbody>
</table>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html';
});
const tbody = document.querySelector('#clientsTable tbody');
async function loadClients() {
const search = document.getElementById('searchClient').value;
let url = '/api/clients?';
if (search) url += `search=${encodeURIComponent(search)}`;
const res = await fetch(url);
const clients = await res.json();
tbody.innerHTML = '';
clients.forEach(c => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${c.id}</td>
<td>${c.phone}</td>
<td>${c.name || '—'}</td>
<td><a href="client.html?id=${c.id}">Профиль</a></td>
`;
tbody.appendChild(tr);
});
}
document.getElementById('searchClientBtn').addEventListener('click', loadClients);
loadClients();
</script>
</body>
</html>

17
public/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ru" data-title="Добро пожаловать" data-description="Гостиница Солнечная бронируйте номера онлайн">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header></header>
<main>
<h1>Добро пожаловать в гостиницу "Солнечная"</h1>
<p>Сервис управления заявками на заселение. Для доступа в панель управления <a href="/login.html">войдите</a>.</p>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
</body>
</html>

46
public/login.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="ru" data-title="Вход в панель" data-description="Авторизация администратора гостиницы">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<title>Вход</title>
</head>
<body>
<header></header>
<main>
<h1>Вход в систему управления</h1>
<form id="loginForm">
<label>Логин</label>
<input type="text" name="login" required>
<label>Пароль</label>
<input type="password" name="password" required>
<button type="submit">Войти</button>
<p id="error" style="color: red; display: none;"></p>
</form>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
login: form.login.value,
password: form.password.value
})
});
if (res.ok) {
window.location.href = '/admin.html';
} else {
const err = document.getElementById('error');
err.textContent = 'Неверный логин или пароль';
err.style.display = 'block';
}
});
</script>
</body>
</html>

30
public/nav.js Normal file
View File

@@ -0,0 +1,30 @@
document.addEventListener('DOMContentLoaded', () => {
const navHTML = `
<nav>
<a href="/">Главная</a>
<a href="/admin.html">Панель управления</a>
<a href="/bookings.html">Заявки</a>
<a href="/clients.html">Клиенты</a>
<a id="logoutLink" href="#" style="display:none;">Выход</a>
</nav>
`;
const header = document.querySelector('header');
if (header) header.innerHTML += navHTML;
// Показать/скрыть выход, проверив сессию
fetch('/api/me')
.then(r => r.json())
.then(data => {
if (data.isAdmin) {
document.getElementById('logoutLink').style.display = 'inline';
}
});
document.addEventListener('click', (e) => {
if (e.target.id === 'logoutLink') {
e.preventDefault();
fetch('/api/logout', { method: 'POST' })
.then(() => window.location.href = '/login.html');
}
});
});

17
public/seo.js Normal file
View File

@@ -0,0 +1,17 @@
(function() {
// Установка заголовка страницы из data-title или имени файла
const pageTitle = document.documentElement.getAttribute('data-title') ||
document.title ||
'Гостиница "Солнечная"';
document.title = pageTitle + ' | ' + (document.documentElement.getAttribute('data-service') || 'Отель');
// Meta description
let metaDesc = document.querySelector('meta[name="description"]');
if (!metaDesc) {
metaDesc = document.createElement('meta');
metaDesc.name = 'description';
document.head.appendChild(metaDesc);
}
metaDesc.content = document.documentElement.getAttribute('data-description') ||
'Управление заявками на заселение в гостинице. Современный сервис бронирования.';
})();

23
public/style.css Normal file
View File

@@ -0,0 +1,23 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; }
header { background: #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem 2rem; }
nav { display: flex; gap: 1rem; flex-wrap: wrap; }
nav a { text-decoration: none; color: #2c3e50; font-weight: 500; padding: 0.5rem 0; }
nav a:hover { color: #3498db; }
main { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
h1, h2 { color: #2c3e50; margin-bottom: 1rem; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #eee; }
th { background: #fafbfc; font-weight: 600; }
tr:hover { background: #f1f4f8; }
button, .btn { padding: 0.5rem 1rem; background: #3498db; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #2980b9; }
form { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; margin: 0 auto; }
input, select, textarea { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; }
label { font-weight: 500; }
.modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); display: flex; justify-content: center; align-items: center; }
.modal-content { background: #fff; padding: 2rem; border-radius: 8px; width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto; }
@media (max-width: 600px) {
header { padding: 0.5rem 1rem; }
nav { flex-direction: column; }
}

172
server.js Normal file
View File

@@ -0,0 +1,172 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const { db, normalizePhone, logAction } = require('./db');
const { sessionMiddleware, ensureAdmin, requireAdmin } = require('./auth');
const { syncBookings } = require('./sync');
const { notifyBookingUpdate } = require('./mailer');
const cron = require('node-cron');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(sessionMiddleware);
// Статика из public
app.use(express.static(path.join(__dirname, 'public')));
// Инициализация администратора
ensureAdmin();
// === API ===
// Вход администратора
app.post('/api/login', (req, res) => {
const { login, password } = req.body;
const admin = db.prepare('SELECT * FROM admins WHERE login = ? AND password = ?').get(login, password);
if (admin) {
req.session.isAdmin = true;
res.json({ success: true });
} else {
res.status(401).json({ error: 'Неверный логин или пароль' });
}
});
// Выход
app.post('/api/logout', (req, res) => {
req.session.destroy();
res.json({ success: true });
});
// Проверка сессии
app.get('/api/me', (req, res) => {
res.json({ isAdmin: !!req.session.isAdmin });
});
// Список заявок (с фильтрами, только для админа)
app.get('/api/bookings', requireAdmin, (req, res) => {
const { status, search, client_id } = req.query;
let query = 'SELECT * FROM bookings WHERE 1=1';
const params = [];
if (status) {
query += ' AND status = ?';
params.push(status);
}
if (client_id) {
query += ' AND user_id = ?';
params.push(client_id);
}
if (search) {
query += ' AND (name LIKE ? OR phone_raw LIKE ? OR comments LIKE ?)';
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
query += ' ORDER BY created_at DESC';
const bookings = db.prepare(query).all(...params);
res.json(bookings);
});
// Детали заявки
app.get('/api/bookings/:id', requireAdmin, (req, res) => {
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id);
if (!booking) return res.status(404).json({ error: 'Заявка не найдена' });
res.json(booking);
});
// Обновление заявки (статус, комментарии, перепривязка клиента)
app.put('/api/bookings/:id', requireAdmin, (req, res) => {
const { id } = req.params;
const { status, comments, phone } = req.body; // phone для перепривязки карточки
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);
});
// Список карточек клиентов
app.get('/api/clients', requireAdmin, (req, res) => {
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);
});
// Профиль клиента с его заявками (сортировка: сначала по дате убыванию, потом по статусу в алфавитном порядке)
app.get('/api/clients/:id', requireAdmin, (req, res) => {
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 });
});
// Ручной запуск синхронизации
app.post('/api/admin/sync', requireAdmin, async (req, res) => {
await syncBookings();
res.json({ success: true, message: 'Синхронизация запущена' });
});
// Планировщик синхронизации каждые 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}`);
});

47
sync.js Normal file
View File

@@ -0,0 +1,47 @@
const axios = require('axios');
const { db, normalizePhone, logAction } = require('./db');
const { notifyNewBooking } = require('./mailer');
async function syncBookings() {
const url = process.env.BOOKING_SERVICE_URL;
const token = process.env.BOOKING_SERVICE_TOKEN;
if (!url || !token) {
console.log('Синхронизация: не указаны BOOKING_SERVICE_URL или TOKEN');
return;
}
try {
const response = await axios.get(`${url}/api/bookings`, {
headers: { 'X-API-Key': token }
});
const bookings = response.data;
const insertStmt = db.prepare(`
INSERT OR IGNORE INTO bookings (external_id, user_id, name, phone_raw, adults, children, checkin_date, checkout_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'Новая')
`);
const selectUser = db.prepare('SELECT id FROM users WHERE phone = ?');
const insertUser = db.prepare('INSERT INTO users (phone, name) VALUES (?, ?)');
for (const b of bookings) {
const normPhone = normalizePhone(b.phone);
let user = selectUser.get(normPhone);
if (!user) {
// Создаём карточку клиента
const info = insertUser.run(normPhone, b.name);
user = { id: info.lastInsertRowid };
}
// Вставляем заявку, если external_id новый
const result = insertStmt.run(b.id, user.id, b.name, b.phone, b.adults, b.children, b.checkin_date, b.checkout_date);
if (result.changes > 0) {
logAction('Новая заявка из внешней системы', { external_id: b.id });
await notifyNewBooking({...b, external_id: b.id});
}
}
console.log('Синхронизация завершена');
} catch (err) {
console.error('Ошибка синхронизации:', err.message);
}
}
module.exports = { syncBookings };