апи
All checks were successful
Deploy hotel / deploy-kdo (push) Successful in 42s

This commit is contained in:
2026-07-21 20:06:43 +05:00
parent d612febefb
commit d539e1f0c0
9 changed files with 410 additions and 56 deletions

View File

@@ -44,6 +44,7 @@ function getBookingsForAdmin(req, res) {
const offset = (page - 1) * limit;
const search = req.query.search || '';
const statusFilter = req.query.status || '';
const since = req.query.since || '';
let whereClause = '1=1';
const params = [];
@@ -59,6 +60,11 @@ function getBookingsForAdmin(req, res) {
params.push(statusFilter);
}
if (since) {
whereClause += ' AND b.created_at >= ?';
params.push(since);
}
db.get(`SELECT COUNT(*) as total FROM bookings b LEFT JOIN rooms r ON b.room_id = r.id WHERE ${whereClause}`, params, (err, countRow) => {
if (err) {
console.error(err);

View File

@@ -51,7 +51,8 @@ function initDatabase(db) {
'discount_percent INTEGER DEFAULT 0',
'discount_amount REAL DEFAULT 0',
'total_price REAL',
'promocode_id INTEGER'
'promocode_id INTEGER',
'remote_id INTEGER'
];
function addColumnSafely(columns, index) {
@@ -341,6 +342,8 @@ function initDatabase(db) {
db.run(`CREATE INDEX IF NOT EXISTS idx_room_images_room_id ON room_images(room_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_seasonal_prices_dates ON seasonal_prices(date_from, date_to)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_seasonal_prices_type ON seasonal_prices(room_type)`);
db.run(`DROP INDEX IF EXISTS idx_bookings_remote_id`);
db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bookings_remote_id ON bookings(remote_id)`);
console.log('✅ Database initialized');
}

View File

@@ -19,7 +19,10 @@ const DEFAULT_SETTINGS = {
hotel_instagram_active: 'true',
telegram_bot_token: '',
telegram_notify_enabled: 'false',
telegram_notify_users: ''
telegram_notify_users: '',
telegram_api_url: 'https://api.telegram.org',
sync_last_sync_at: '',
sync_first_sync_done: 'false'
};
function init(database) {

225
modules/sync/index.js Normal file
View File

@@ -0,0 +1,225 @@
let db;
let settingsModule;
let emailModule;
let telegramModule;
let jwt = null;
let syncTimer = null;
let running = false;
const PRIMARY_URL = (process.env.PRIMARY_URL || '').replace(/\/+$/, '');
const PRIMARY_LOGIN = process.env.PRIMARY_LOGIN || '';
const PRIMARY_PASSWORD = process.env.PRIMARY_PASSWORD || '';
const PRIMARY_SYNC_INTERVAL = parseInt(process.env.PRIMARY_SYNC_INTERVAL) || 60;
function init(database, settings, email, telegram) {
db = database;
settingsModule = settings;
emailModule = email;
telegramModule = telegram;
}
function start() {
if (!PRIMARY_URL || !PRIMARY_LOGIN || !PRIMARY_PASSWORD) {
console.log('[SYNC] PRIMARY_URL/LOGIN/PASSWORD not set — replica mode disabled');
return;
}
console.log(`[SYNC] Replica mode: pulling from ${PRIMARY_URL} every ${PRIMARY_SYNC_INTERVAL}s`);
loginAndSync();
}
function stop() {
running = false;
if (syncTimer) {
clearTimeout(syncTimer);
syncTimer = null;
}
}
function loginAndSync() {
login((err) => {
if (err) {
console.error(`[SYNC] Login failed: ${err.message}. Retrying in 30s...`);
syncTimer = setTimeout(loginAndSync, 30000);
return;
}
running = true;
settingsModule.get('sync_first_sync_done', (err, val) => {
if (err || val !== 'true') {
firstSync();
} else {
syncLoop();
}
});
});
}
function login(callback) {
fetch(`${PRIMARY_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: PRIMARY_LOGIN, password: PRIMARY_PASSWORD })
})
.then(res => {
if (!res.ok) return res.json().then(d => { throw new Error(`HTTP ${res.status}: ${d.error || 'unknown'}`); });
return res.json();
})
.then(data => {
jwt = data.token;
console.log('[SYNC] Logged in to primary server');
callback(null);
})
.catch(err => {
callback(err);
});
}
function firstSync() {
console.log('[SYNC] First sync — pulling ALL bookings...');
apiGet('/api/admin/bookings?limit=99999', (err, result) => {
if (err) {
console.error(`[SYNC] First sync failed: ${err.message}. Retrying in 30s...`);
syncTimer = setTimeout(firstSync, 30000);
return;
}
const bookings = result.data || [];
insertBatch(bookings, false, (insertCount) => {
console.log(`[SYNC] First sync done: ${insertCount} bookings imported (no notifications)`);
settingsModule.set('sync_first_sync_done', 'true', (err) => {
if (err) console.error('[SYNC] Failed to save sync_first_sync_done:', err);
});
saveLastSync(() => {
scheduleNext();
});
});
});
}
function syncLoop() {
settingsModule.get('sync_last_sync_at', (err, lastSyncAt) => {
if (err) {
console.error('[SYNC] Failed to read last_sync_at:', err);
scheduleNext();
return;
}
const since = lastSyncAt ? encodeURIComponent(lastSyncAt) : '';
const url = since ? `/api/admin/bookings?limit=99999&since=${since}` : '/api/admin/bookings?limit=99999';
apiGet(url, (err, result) => {
if (err) {
if (err.message.includes('401') || err.message.includes('token') || err.message.includes('jwt')) {
console.log('[SYNC] Token expired, re-logging in...');
loginAndSync();
return;
}
console.error(`[SYNC] Sync failed: ${err.message}. Retry at next interval.`);
scheduleNext();
return;
}
const bookings = result.data || [];
if (bookings.length === 0) {
saveLastSync(() => scheduleNext());
return;
}
insertBatch(bookings, true, (insertCount, notifyCount) => {
console.log(`[SYNC] Pulled ${bookings.length}, new: ${insertCount}, notified: ${notifyCount}`);
saveLastSync(() => scheduleNext());
});
});
});
}
function scheduleNext() {
if (!running) return;
syncTimer = setTimeout(syncLoop, PRIMARY_SYNC_INTERVAL * 1000);
}
function saveLastSync(callback) {
const now = new Date().toISOString();
settingsModule.set('sync_last_sync_at', now, (err) => {
if (err) console.error('[SYNC] Failed to save last_sync_at:', err);
callback();
});
}
function apiGet(path, callback) {
if (!jwt) return callback(new Error('Not authenticated'));
fetch(PRIMARY_URL + path, {
method: 'GET',
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json'
}
})
.then(res => {
if (!res.ok) {
if (res.status === 401) {
jwt = null;
return res.json().then(d => { throw Object.assign(new Error('401 Unauthorized'), { code: 401 }); });
}
return res.json().then(d => { throw new Error(`HTTP ${res.status}: ${d.error || 'unknown'}`); });
}
return res.json();
})
.then(data => callback(null, data))
.catch(err => callback(err));
}
function insertBatch(bookings, withNotifications, callback) {
let inserted = 0;
let notified = 0;
const total = bookings.length;
const stmt = db.prepare(`INSERT OR IGNORE INTO bookings (remote_id, name, phone, adults, children, checkin_date, checkout_date, wishes, status, room_type, room_id, base_price, discount_percent, discount_amount, total_price, promocode_id, comment, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
bookings.forEach((b, index) => {
stmt.run(
b.id, b.name, b.phone, b.adults, b.children,
b.checkin_date, b.checkout_date, b.wishes,
b.status || 'новая', b.room_type, b.room_id,
b.base_price, b.discount_percent, b.discount_amount,
b.total_price, b.promocode_id, b.comment, b.created_at,
function(err) {
if (err) {
console.error('[SYNC] Insert error:', err.message);
} else if (this.changes > 0) {
inserted++;
if (withNotifications && b.status !== 'отменена') {
sendNotifications(b);
notified++;
}
}
if (index === total - 1) {
stmt.finalize();
callback(inserted, notified);
}
}
);
});
if (total === 0) {
stmt.finalize();
callback(0, 0);
}
}
function sendNotifications(booking) {
try {
if (telegramModule && typeof telegramModule.sendBookingNotification === 'function') {
telegramModule.sendBookingNotification(booking);
}
} catch(e) {
console.error('[SYNC] Telegram notify error:', e.message);
}
try {
if (emailModule && typeof emailModule.sendBookingConfirmation === 'function') {
emailModule.sendBookingConfirmation(booking);
}
} catch(e) {
console.error('[SYNC] Email notify error:', e.message);
}
}
module.exports = { init, start, stop };

View File

@@ -9,6 +9,10 @@ let pollingFatalLogged = false;
let pollingActiveLogged = false;
let lastSendErrors = {};
let connectionMode = null;
let connectionLoggedKey = null;
const DEFAULT_API = 'https://api.telegram.org';
function init(database, settings) {
db = database;
@@ -22,38 +26,109 @@ function getSettings(callback) {
});
}
function sendMessage(token, chatId, text, callback) {
const body = JSON.stringify({
chat_id: chatId,
text: text,
parse_mode: 'HTML'
});
function isNetworkError(err) {
const msg = (err.message || '').toLowerCase();
return msg.includes('econnrefused') || msg.includes('etimedout') ||
msg.includes('enotfound') || msg.includes('fetch failed') ||
msg.includes('network') || err.name === 'TypeError' ||
err.cause !== undefined;
}
fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
})
.then(res => res.json())
.then(data => {
if (!data.ok) {
const key = `${chatId}:${data.error_code}:${data.description}`;
if (lastSendErrors[key] !== true) {
lastSendErrors[key] = true;
console.error(`Telegram sendMessage: chat ${chatId}${data.description}`);
function apiCall(settings, method, body, callback) {
const token = settings.telegram_bot_token;
const apiUrl = (settings.telegram_api_url || DEFAULT_API).replace(/\/+$/, '');
const proxyUrl = apiUrl !== DEFAULT_API ? apiUrl : null;
const urlsToTry = proxyUrl ? [proxyUrl, DEFAULT_API] : [DEFAULT_API];
function tryNext(index) {
if (index >= urlsToTry.length) {
return callback(new Error('Telegram недоступен: все адреса недоступны'));
}
const url = urlsToTry[index];
const fullUrl = `${url}/bot${token}/${method}`;
const fetchOpts = body ? {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
} : {};
fetch(fullUrl, fetchOpts)
.then(res => res.json().then(data => ({ status: res.status, data })))
.then(result => {
const data = result.data;
if (!data.ok) {
const err = new Error(data.description);
err.code = data.error_code;
err.fromProxy = url !== DEFAULT_API;
return callback(err);
}
if (callback) callback(new Error(data.description));
} else {
if (callback) callback(null, data);
if (url !== DEFAULT_API) {
updateConnectionMode('proxied', url);
} else {
updateConnectionMode('direct', null);
}
callback(null, data);
})
.catch(fetchErr => {
if (isNetworkError(fetchErr) && index < urlsToTry.length - 1) {
tryNext(index + 1);
} else if (isNetworkError(fetchErr)) {
updateConnectionMode('blocked', null);
callback(fetchErr);
} else {
callback(fetchErr);
}
});
}
tryNext(0);
}
function updateConnectionMode(mode, proxyUrl) {
const key = mode + (proxyUrl || '');
if (connectionLoggedKey === key) return;
connectionLoggedKey = key;
connectionMode = mode;
if (mode === 'direct') {
console.log('Telegram подключён напрямую');
} else if (mode === 'proxied') {
console.log(`Telegram подключён через прокси-сервер: ${proxyUrl}`);
} else if (mode === 'blocked') {
console.error('Telegram заблокирован. Укажите прокси-URL в настройках Telegram.');
}
}
function sendMessage(token, chatId, text, callback) {
getSettings((err, settings) => {
if (err) {
if (callback) callback(err);
return;
}
})
.catch(err => {
const key = `fetch:${chatId}:${err.message}`;
if (lastSendErrors[key] !== true) {
lastSendErrors[key] = true;
console.error(`Telegram sendMessage: chat ${chatId}${err.message}`);
}
if (callback) callback(err);
apiCall(settings, 'sendMessage', { chat_id: chatId, text: text, parse_mode: 'HTML' }, (err, data) => {
if (!err) {
if (callback) callback(null, data);
return;
}
if (!err.code) {
const key = `network:${chatId}`;
if (lastSendErrors[key] !== true) {
lastSendErrors[key] = true;
console.error(`Telegram sendMessage: chat ${chatId}${err.message}`);
}
} else {
const key = `${chatId}:${err.code}:${err.message}`;
if (lastSendErrors[key] !== true) {
lastSendErrors[key] = true;
console.error(`Telegram sendMessage: chat ${chatId}${err.message}`);
}
}
if (callback) callback(err);
});
});
}
@@ -107,24 +182,33 @@ function startPolling() {
}
const token = settings.telegram_bot_token;
fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=${lastUpdateId + 1}&timeout=10`)
.then(res => res.json())
.then(data => {
if (!data.ok) {
if (data.error_code === 404) {
apiCall(settings, `getUpdates?offset=${lastUpdateId + 1}&timeout=10`, null, (err, data) => {
if (err) {
if (err.code === 404) {
if (!pollingFatalLogged) {
pollingFatalLogged = true;
console.error(`Telegram polling: бот не найден — ${data.description}. Polling остановлен до смены токена.`);
console.error(`Telegram polling: бот не найден — ${err.message}. Polling остановлен до смены токена.`);
}
return;
}
if (err.code) {
pollErrorCount++;
const backoff = Math.min(5000 * Math.pow(2, pollErrorCount - 1), 300000);
const errKey = `${err.code}:${err.message}`;
if (lastPollErrorKey !== errKey) {
lastPollErrorKey = errKey;
console.error(`Telegram polling: ${err.message}`);
}
schedulePolling(backoff);
return;
}
pollErrorCount++;
const backoff = Math.min(5000 * Math.pow(2, pollErrorCount - 1), 300000);
const errKey = `${data.error_code}:${data.description}`;
const errKey = `network:${err.message}`;
if (lastPollErrorKey !== errKey) {
lastPollErrorKey = errKey;
console.error(`Telegram polling: ${data.description}`);
}
schedulePolling(backoff);
return;
@@ -135,7 +219,8 @@ function startPolling() {
if (!pollingActiveLogged) {
pollingActiveLogged = true;
console.log('Telegram polling active — бот слушает сообщения');
const modeText = connectionMode === 'proxied' ? ' (через прокси)' : '';
console.log(`Telegram polling active — бот слушает сообщения${modeText}`);
}
if (data.result && data.result.length > 0) {
@@ -149,16 +234,6 @@ function startPolling() {
});
}
schedulePolling(1000);
})
.catch(err => {
pollErrorCount++;
const backoff = Math.min(5000 * Math.pow(2, pollErrorCount - 1), 300000);
const errKey = `fetch:${err.message}`;
if (lastPollErrorKey !== errKey) {
lastPollErrorKey = errKey;
console.error(`Telegram polling: ${err.message}`);
}
schedulePolling(backoff);
});
});
}
@@ -183,6 +258,8 @@ function resetPollState() {
pollingFatalLogged = false;
pollingActiveLogged = false;
lastSendErrors = {};
connectionMode = null;
connectionLoggedKey = null;
startPolling();
}
@@ -199,7 +276,9 @@ function sendTestMessage(settings, callback) {
chatIds.forEach((chatId, index) => {
sendMessage(token, chatId, '<b>Hotel 777</b> — Тестовое уведомление Telegram. Бот работает корректно.', (err) => {
if (err) {
errors.push(`chat_id ${chatId}: ${err.message}`);
let errText = err.message;
if (!err.code) errText = `${errText} (проверьте подключение или укажите прокси-URL)`;
errors.push(`chat_id ${chatId}: ${errText}`);
} else {
sent++;
}
@@ -222,13 +301,15 @@ function setupRoutes(app, authenticateToken, requireAdmin) {
res.json({
telegram_bot_token_masked: token ? maskToken(token) : '',
telegram_notify_enabled: settings.telegram_notify_enabled || 'false',
telegram_notify_users: settings.telegram_notify_users || ''
telegram_notify_users: settings.telegram_notify_users || '',
telegram_api_url: settings.telegram_api_url || DEFAULT_API,
telegram_proxy_status: connectionMode || 'unknown'
});
});
});
app.put('/api/admin/settings/telegram', authenticateToken, requireAdmin, (req, res) => {
const { telegram_bot_token, telegram_notify_enabled, telegram_notify_users } = req.body;
const { telegram_bot_token, telegram_notify_enabled, telegram_notify_users, telegram_api_url } = req.body;
const updates = [];
if (telegram_bot_token !== undefined && !(telegram_bot_token || '').startsWith('****')) {
@@ -240,6 +321,10 @@ function setupRoutes(app, authenticateToken, requireAdmin) {
if (telegram_notify_users !== undefined) {
updates.push(['telegram_notify_users', telegram_notify_users]);
}
if (telegram_api_url !== undefined) {
const url = (telegram_api_url || '').trim();
updates.push(['telegram_api_url', url || DEFAULT_API]);
}
let processed = 0;
if (updates.length === 0) return res.json({ message: 'No changes' });
@@ -286,4 +371,4 @@ function maskToken(val) {
return '****';
}
module.exports = { init, setupRoutes, sendBookingNotification, sendTestMessage, startPolling, stopPolling };
module.exports = { init, setupRoutes, sendBookingNotification, sendTestMessage, startPolling, stopPolling, connectionMode };

View File

@@ -599,6 +599,16 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
<input type="text" class="form-control" id="telegramNotifyUsers" placeholder="123456789,987654321">
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-6">
<label class="form-label">API URL (обход блокировок)</label>
<input type="text" class="form-control" id="telegramApiUrl" placeholder="https://api.telegram.org">
<small style="color: #94a3b8;">Если Telegram заблокирован, укажите адрес прокси-сервера (nginx, socat и т.д.)</small>
</div>
<div class="col-md-6 d-flex align-items-end">
<span id="telegramStatus" class="badge bg-secondary">Подключение: —</span>
</div>
</div>
<div class="mt-3 d-flex align-items-center gap-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="telegramEnabled" style="width: 50px; height: 24px; cursor: pointer;">
@@ -2615,9 +2625,25 @@ async function loadTelegramSettings() {
document.getElementById('telegramBotToken').value = data.telegram_bot_token_masked || '';
document.getElementById('telegramNotifyUsers').value = data.telegram_notify_users || '';
document.getElementById('telegramEnabled').checked = data.telegram_notify_enabled === 'true';
document.getElementById('telegramApiUrl').value = data.telegram_api_url || 'https://api.telegram.org';
updateTelegramStatus(data.telegram_proxy_status || 'unknown');
} catch(e) {}
}
function updateTelegramStatus(status) {
const el = document.getElementById('telegramStatus');
if (!el) return;
const map = {
direct: { cls: 'bg-success', txt: 'Подключение: напрямую' },
proxied: { cls: 'bg-warning text-dark', txt: 'Подключение: через прокси' },
blocked: { cls: 'bg-danger', txt: 'Подключение: заблокирован' },
unknown: { cls: 'bg-secondary', txt: 'Подключение: —' }
};
const info = map[status] || map.unknown;
el.className = 'badge ' + info.cls;
el.textContent = info.txt;
}
async function saveTelegramSettings() {
const tokenVal = document.getElementById('telegramBotToken').value;
const token = tokenVal.startsWith('****') ? '' : tokenVal.trim();
@@ -2627,7 +2653,8 @@ async function saveTelegramSettings() {
const data = {
telegram_bot_token: token,
telegram_notify_users: usersStr,
telegram_notify_enabled: enabled ? 'true' : 'false'
telegram_notify_enabled: enabled ? 'true' : 'false',
telegram_api_url: document.getElementById('telegramApiUrl').value.trim()
};
try {
await api('/api/admin/settings/telegram', { method: 'PUT', body: JSON.stringify(data) });

View File

@@ -886,6 +886,7 @@
active = instagramActive === 'true';
}
el.setAttribute('href', href);
if (href === '#') active = false;
if (!active) el.style.display = 'none';
});

View File

@@ -335,7 +335,7 @@ document.getElementById('bookingForm').addEventListener('submit', async function
});
// Smooth scroll for nav links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
document.querySelectorAll('a[href^="#"]:not([data-phone])').forEach(anchor => {
anchor.addEventListener('click', function(e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));

View File

@@ -268,6 +268,7 @@ const backupModule = require('./modules/backup');
const seasonalPricesModule = require('./modules/seasonalPrices');
const emailModule = require('./modules/email');
const telegramModule = require('./modules/telegram');
const syncModule = require('./modules/sync');
const reportsModule = require('./modules/reports');
const activitiesModule = require('./modules/activities');
const heroModule = require('./modules/hero');
@@ -286,6 +287,7 @@ modules.backup = backupModule;
modules.seasonalPrices = seasonalPricesModule;
modules.email = emailModule;
modules.telegram = telegramModule;
modules.sync = syncModule;
modules.reports = reportsModule;
modules.activities = activitiesModule;
modules.hero = heroModule;
@@ -302,6 +304,7 @@ backupModule.init(db, dbPath);
seasonalPricesModule.init(db);
emailModule.init(db, settingsModule);
telegramModule.init(db, settingsModule);
syncModule.init(db, settingsModule, emailModule, telegramModule);
reportsModule.init(db);
activitiesModule.init(db);
heroModule.init(db);
@@ -506,6 +509,7 @@ convertImages().then(() => {
await runStartupTests(db, modules);
console.log(`✅ Hotel 777 server running on http://localhost:${PORT}`);
setTimeout(() => { telegramModule.startPolling(); }, 2000);
setTimeout(() => { syncModule.start(); }, 3000);
});
function gracefulShutdown(signal) {