650 lines
32 KiB
JavaScript
650 lines
32 KiB
JavaScript
const express = require('express');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const sqlite3 = require('sqlite3').verbose();
|
||
const sharp = require('sharp');
|
||
const jwt = require('jsonwebtoken');
|
||
const bcrypt = require('bcryptjs');
|
||
require('dotenv').config();
|
||
|
||
const app = express();
|
||
const PORT = process.env.PORT || 3000;
|
||
const API_KEY = process.env.HOTEL777KEY;
|
||
const ADMIN_LOGIN = process.env.ADMIN_LOGIN;
|
||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
||
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
|
||
|
||
const ROOM_PRICES = {
|
||
'Эконом': 2500,
|
||
'Стандарт': 4000,
|
||
'VIP Люкс': 8000
|
||
};
|
||
|
||
function initDefaultRooms() {
|
||
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
|
||
if (err) return console.error('Check rooms count error:', err);
|
||
if (row.count > 0) return;
|
||
const defaults = [
|
||
{ type: 'Эконом', name: 'Эконом 1', description: 'Бюджетный номер', rooms_count: 3, single_beds: 2, double_beds: 0, has_sofa: 0, has_ac: 0, has_wifi: 1, has_shower: 1, max_guests: 2, price_per_guest: 2500, image_path: null, is_active: 1 },
|
||
{ type: 'Стандарт', name: 'Стандарт 1', description: 'Комфортный номер', rooms_count: 2, single_beds: 0, double_beds: 1, has_sofa: 1, has_ac: 1, has_wifi: 1, has_shower: 1, max_guests: 3, price_per_guest: 4000, image_path: null, is_active: 1 },
|
||
{ type: 'VIP Люкс', name: 'VIP Люкс 1', description: 'Премиум номер', rooms_count: 1, single_beds: 0, double_beds: 1, has_sofa: 1, has_ac: 1, has_wifi: 1, has_shower: 1, max_guests: 4, price_per_guest: 8000, image_path: null, is_active: 1 }
|
||
];
|
||
const stmt = db.prepare(`INSERT INTO rooms (type, name, description, rooms_count, single_beds, double_beds, has_sofa, has_ac, has_wifi, has_shower, max_guests, price_per_guest, image_path, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||
defaults.forEach(r => stmt.run(r.type, r.name, r.description, r.rooms_count, r.single_beds, r.double_beds, r.has_sofa, r.has_ac, r.has_wifi, r.has_shower, r.max_guests, r.price_per_guest, r.image_path, r.is_active));
|
||
stmt.finalize();
|
||
console.log('✅ Default rooms initialized');
|
||
});
|
||
}
|
||
|
||
function calculateNights(checkin, checkout) {
|
||
const ci = new Date(checkin);
|
||
const co = new Date(checkout);
|
||
return Math.ceil((co - ci) / (1000 * 60 * 60 * 24));
|
||
}
|
||
|
||
function calculateBasePrice(roomType, checkin, checkout) {
|
||
const pricePerNight = ROOM_PRICES[roomType] || 0;
|
||
const nights = calculateNights(checkin, checkout);
|
||
return pricePerNight * nights;
|
||
}
|
||
|
||
function validatePromocode(promocode, callback) {
|
||
if (!promocode) return callback(null, null);
|
||
const now = new Date().toISOString();
|
||
db.get(`SELECT * FROM promocodes WHERE code = ? AND is_active = 1`, [promocode], (err, row) => {
|
||
if (err || !row) return callback(null, null);
|
||
if (row.valid_from && row.valid_from > now) return callback(null, null);
|
||
if (row.valid_to && row.valid_to < now) return callback(null, null);
|
||
if (row.valid_days) {
|
||
const createdDate = new Date(row.created_at);
|
||
const expireDate = new Date(createdDate.getTime() + row.valid_days * 24 * 60 * 60 * 1000);
|
||
if (expireDate < new Date()) return callback(null, null);
|
||
}
|
||
callback(null, row);
|
||
});
|
||
}
|
||
|
||
if (!API_KEY) {
|
||
console.error('FATAL: HOTEL777KEY environment variable not set');
|
||
process.exit(1);
|
||
}
|
||
|
||
app.use(express.json());
|
||
app.use((req, res, next) => {
|
||
res.header('Access-Control-Allow-Origin', '*');
|
||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
||
next();
|
||
});
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
const dataDir = path.join(__dirname, 'data');
|
||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir);
|
||
const dbPath = path.join(dataDir, 'bookings.db');
|
||
const db = new sqlite3.Database(dbPath);
|
||
|
||
db.run(`CREATE TABLE IF NOT EXISTS bookings (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
phone TEXT NOT NULL,
|
||
adults INTEGER NOT NULL,
|
||
children INTEGER NOT NULL,
|
||
checkin_date TEXT NOT NULL,
|
||
checkout_date TEXT NOT NULL,
|
||
wishes TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)`, (err) => {
|
||
if (err) console.error('Table creation error:', err);
|
||
});
|
||
|
||
db.run(`ALTER TABLE bookings ADD COLUMN wishes TEXT`, (err) => {
|
||
if (err && !err.message.includes('duplicate column name')) {
|
||
console.error('Migration error:', err);
|
||
}
|
||
});
|
||
|
||
db.run(`ALTER TABLE bookings ADD COLUMN status TEXT DEFAULT 'новая'`, (err) => {
|
||
if (err && !err.message.includes('duplicate column name')) {
|
||
console.error('Migration error:', err);
|
||
}
|
||
});
|
||
|
||
db.run(`ALTER TABLE bookings ADD COLUMN room_type TEXT`, (err) => {
|
||
if (err && !err.message.includes('duplicate column name')) {
|
||
console.error('Migration error:', err);
|
||
}
|
||
});
|
||
|
||
db.run(`ALTER TABLE bookings ADD COLUMN comment TEXT`, (err) => {
|
||
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
|
||
});
|
||
db.run(`ALTER TABLE bookings ADD COLUMN base_price REAL`, (err) => {
|
||
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
|
||
});
|
||
db.run(`ALTER TABLE bookings ADD COLUMN discount_percent INTEGER DEFAULT 0`, (err) => {
|
||
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
|
||
});
|
||
db.run(`ALTER TABLE bookings ADD COLUMN discount_amount REAL DEFAULT 0`, (err) => {
|
||
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
|
||
});
|
||
db.run(`ALTER TABLE bookings ADD COLUMN total_price REAL`, (err) => {
|
||
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
|
||
});
|
||
db.run(`ALTER TABLE bookings ADD COLUMN promocode_id INTEGER`, (err) => {
|
||
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
|
||
});
|
||
|
||
db.run(`CREATE TABLE IF NOT EXISTS promocodes (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
code TEXT NOT NULL UNIQUE,
|
||
discount_percent INTEGER NOT NULL CHECK(discount_percent BETWEEN 1 AND 99),
|
||
valid_from DATETIME,
|
||
valid_to DATETIME,
|
||
valid_days INTEGER,
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)`, (err) => {
|
||
if (err) console.error('Promocodes table creation error:', err);
|
||
});
|
||
|
||
db.run(`CREATE TABLE IF NOT EXISTS rooms (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
type TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
description TEXT,
|
||
rooms_count INTEGER DEFAULT 1,
|
||
single_beds INTEGER DEFAULT 0,
|
||
double_beds INTEGER DEFAULT 0,
|
||
has_sofa INTEGER DEFAULT 0,
|
||
has_ac INTEGER DEFAULT 0,
|
||
has_wifi INTEGER DEFAULT 0,
|
||
has_shower INTEGER DEFAULT 0,
|
||
max_guests INTEGER DEFAULT 2,
|
||
price_per_guest INTEGER NOT NULL,
|
||
image_path TEXT,
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)`, (err) => {
|
||
if (err) console.error('Rooms table creation error:', err);
|
||
else initDefaultRooms();
|
||
});
|
||
|
||
db.run(`CREATE TABLE IF NOT EXISTS booking_history (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
booking_id INTEGER NOT NULL,
|
||
user_id INTEGER,
|
||
user_login TEXT,
|
||
field TEXT NOT NULL,
|
||
old_value TEXT,
|
||
new_value TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (booking_id) REFERENCES bookings(id)
|
||
)`, (err) => {
|
||
if (err) console.error('Booking history table creation error:', err);
|
||
});
|
||
|
||
function logHistory(bookingId, userId, userLogin, field, oldValue, newValue) {
|
||
db.run(`INSERT INTO booking_history (booking_id, user_id, user_login, field, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)`,
|
||
[bookingId, userId, userLogin, field, oldValue, newValue], (err) => {
|
||
if (err) console.error('History log error:', err);
|
||
});
|
||
}
|
||
|
||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
login TEXT NOT NULL UNIQUE,
|
||
password_hash TEXT NOT NULL,
|
||
full_name TEXT,
|
||
email TEXT,
|
||
role TEXT NOT NULL DEFAULT 'user',
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)`, (err) => {
|
||
if (err) console.error('Users table creation error:', err);
|
||
else syncAdmin();
|
||
});
|
||
|
||
function syncAdmin() {
|
||
if (!ADMIN_LOGIN || !ADMIN_PASSWORD) {
|
||
console.warn('WARNING: ADMIN_LOGIN or ADMIN_PASSWORD not set, skipping admin sync');
|
||
return;
|
||
}
|
||
const hash = bcrypt.hashSync(ADMIN_PASSWORD, 10);
|
||
db.get(`SELECT id, role FROM users WHERE login = ?`, [ADMIN_LOGIN], (err, row) => {
|
||
if (err) { console.error('Admin sync error:', err); return; }
|
||
if (row) {
|
||
db.run(`UPDATE users SET password_hash = ?, role = 'admin' WHERE login = ?`, [hash, ADMIN_LOGIN], (err) => {
|
||
if (err) console.error('Admin update error:', err);
|
||
else console.log(`✅ Superadmin "${ADMIN_LOGIN}" updated from .env`);
|
||
});
|
||
} else {
|
||
db.run(`INSERT INTO users (login, password_hash, full_name, email, role) VALUES (?, ?, 'Администратор', NULL, 'admin')`,
|
||
[ADMIN_LOGIN, hash], (err) => {
|
||
if (err) console.error('Admin creation error:', err);
|
||
else console.log(`✅ Superadmin "${ADMIN_LOGIN}" created from .env`);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function authenticateToken(req, res, next) {
|
||
const authHeader = req.headers['authorization'];
|
||
const token = authHeader && authHeader.split(' ')[1];
|
||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||
if (err) return res.status(403).json({ error: 'Invalid or expired token' });
|
||
req.user = user;
|
||
next();
|
||
});
|
||
}
|
||
|
||
function requireAdmin(req, res, next) {
|
||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||
next();
|
||
}
|
||
|
||
async function convertImages() {
|
||
const imgDir = path.join(__dirname, 'public', 'img');
|
||
if (!fs.existsSync(imgDir)) {
|
||
console.log('Папка img не найдена, пропускаем конвертацию.');
|
||
return;
|
||
}
|
||
const files = fs.readdirSync(imgDir);
|
||
for (const file of files) {
|
||
const ext = path.extname(file).toLowerCase();
|
||
if (ext === '.jpg' || ext === '.jpeg' || ext === '.png') {
|
||
const name = path.parse(file).name;
|
||
const webpPath = path.join(imgDir, `${name}.webp`);
|
||
if (!fs.existsSync(webpPath)) {
|
||
try {
|
||
await sharp(path.join(imgDir, file))
|
||
.webp({ quality: 85 })
|
||
.toFile(webpPath);
|
||
console.log(`✅ Сконвертировано: ${file} -> ${name}.webp`);
|
||
} catch (err) {
|
||
console.error(`❌ Ошибка при конвертации ${file}:`, err);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
app.post('/api/bookings', (req, res) => {
|
||
const { name, phone, adults, children, checkin, checkout, wishes, room, promocode } = req.body;
|
||
if (!name || !phone || !adults || !checkin || !checkout) {
|
||
return res.status(400).json({ error: 'Missing required fields' });
|
||
}
|
||
const basePrice = calculateBasePrice(room, checkin, checkout);
|
||
validatePromocode(promocode, (err, promo) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
let discountPercent = 0;
|
||
let promocodeId = null;
|
||
if (promo) {
|
||
discountPercent = promo.discount_percent;
|
||
promocodeId = promo.id;
|
||
}
|
||
const safeBasePrice = basePrice || 0;
|
||
const discountAmount = Math.round(safeBasePrice * discountPercent / 100);
|
||
const totalPrice = safeBasePrice - discountAmount;
|
||
const stmt = db.prepare(`INSERT INTO bookings (name, phone, adults, children, checkin_date, checkout_date, wishes, status, room_type, base_price, discount_percent, discount_amount, total_price, promocode_id)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, 'новая', ?, ?, ?, ?, ?, ?)`);
|
||
stmt.run(name, phone, parseInt(adults), parseInt(children || 0), checkin, checkout, wishes || null, room || null,
|
||
safeBasePrice || null, discountPercent || 0, discountAmount || 0, totalPrice || null, promocodeId, function(err) {
|
||
if (err) {
|
||
console.error(err);
|
||
return res.status(500).json({ error: 'Database error' });
|
||
}
|
||
res.status(201).json({ id: this.lastID, message: 'Booking saved', base_price: safeBasePrice, discount_percent: discountPercent, discount_amount: discountAmount, total_price: totalPrice });
|
||
});
|
||
stmt.finalize();
|
||
});
|
||
});
|
||
|
||
app.get('/api/admin/bookings', authenticateToken, (req, res) => {
|
||
db.all(`SELECT b.*, p.code as promocode_code FROM bookings b
|
||
LEFT JOIN promocodes p ON b.promocode_id = p.id
|
||
ORDER BY b.checkin_date ASC`, [], (err, rows) => {
|
||
if (err) {
|
||
console.error(err);
|
||
return res.status(500).json({ error: 'Database error' });
|
||
}
|
||
res.json(rows);
|
||
});
|
||
});
|
||
|
||
app.patch('/api/admin/bookings/:id', authenticateToken, requireAdmin, (req, res) => {
|
||
console.log('PATCH /api/admin/bookings/:id hit', req.params.id, req.body);
|
||
const bookingId = parseInt(req.params.id);
|
||
const { status } = req.body;
|
||
const validStatuses = ['новая', 'оплачена', 'зарезервирована', 'заселена', 'выехала', 'отменена'];
|
||
if (!status || !validStatuses.includes(status)) {
|
||
return res.status(400).json({ error: 'Invalid status. Valid: ' + validStatuses.join(', ') });
|
||
}
|
||
db.get(`SELECT status FROM bookings WHERE id = ?`, [bookingId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!row) return res.status(404).json({ error: 'Booking not found' });
|
||
const oldValue = row.status;
|
||
db.run(`UPDATE bookings SET status = ? WHERE id = ?`, [status, bookingId], (err) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
logHistory(bookingId, req.user.id, req.user.login, 'status', oldValue, status);
|
||
db.get(`SELECT b.*, p.code as promocode_code FROM bookings b LEFT JOIN promocodes p ON b.promocode_id = p.id WHERE b.id = ?`, [bookingId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json({ message: 'Status updated', booking: row });
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
app.patch('/api/admin/bookings/:id/room', authenticateToken, requireAdmin, (req, res) => {
|
||
const bookingId = parseInt(req.params.id);
|
||
const { room_type } = req.body;
|
||
const validRooms = ['Эконом', 'Стандарт', 'VIP Люкс'];
|
||
if (!room_type || !validRooms.includes(room_type)) {
|
||
return res.status(400).json({ error: 'Invalid room type. Valid: ' + validRooms.join(', ') });
|
||
}
|
||
db.get(`SELECT room_type, checkin_date, checkout_date, discount_percent FROM bookings WHERE id = ?`, [bookingId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!row) return res.status(404).json({ error: 'Booking not found' });
|
||
const oldValue = row.room_type || 'Не указан';
|
||
const basePrice = calculateBasePrice(room_type, row.checkin_date, row.checkout_date);
|
||
const discountAmount = Math.round(basePrice * (row.discount_percent || 0) / 100);
|
||
const totalPrice = basePrice - discountAmount;
|
||
db.run(`UPDATE bookings SET room_type = ?, base_price = ?, discount_amount = ?, total_price = ? WHERE id = ?`,
|
||
[room_type, basePrice, discountAmount, totalPrice, bookingId], (err) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
logHistory(bookingId, req.user.id, req.user.login, 'room_type', oldValue, room_type);
|
||
db.get(`SELECT b.*, p.code as promocode_code FROM bookings b LEFT JOIN promocodes p ON b.promocode_id = p.id WHERE b.id = ?`, [bookingId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json({ message: 'Room updated', booking: row });
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
app.patch('/api/admin/bookings/:id/comment', authenticateToken, requireAdmin, (req, res) => {
|
||
const bookingId = parseInt(req.params.id);
|
||
const { comment } = req.body;
|
||
db.get(`SELECT comment FROM bookings WHERE id = ?`, [bookingId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!row) return res.status(404).json({ error: 'Booking not found' });
|
||
const oldValue = row.comment || 'Нет';
|
||
db.run(`UPDATE bookings SET comment = ? WHERE id = ?`, [comment || null, bookingId], (err) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
logHistory(bookingId, req.user.id, req.user.login, 'comment', oldValue, comment || 'Нет');
|
||
db.get(`SELECT b.*, p.code as promocode_code FROM bookings b LEFT JOIN promocodes p ON b.promocode_id = p.id WHERE b.id = ?`, [bookingId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json({ message: 'Comment updated', booking: row });
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
app.patch('/api/admin/bookings/:id/discount', authenticateToken, requireAdmin, (req, res) => {
|
||
const bookingId = parseInt(req.params.id);
|
||
const { discount_percent } = req.body;
|
||
if (discount_percent === undefined || discount_percent < 0 || discount_percent > 99) {
|
||
return res.status(400).json({ error: 'Discount percent must be between 0 and 99' });
|
||
}
|
||
db.get(`SELECT base_price, discount_percent FROM bookings WHERE id = ?`, [bookingId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!row) return res.status(404).json({ error: 'Booking not found' });
|
||
const oldValue = row.discount_percent || 0;
|
||
const basePrice = row.base_price || 0;
|
||
const discountAmount = Math.round(basePrice * discount_percent / 100);
|
||
const totalPrice = basePrice - discountAmount;
|
||
db.run(`UPDATE bookings SET discount_percent = ?, discount_amount = ?, total_price = ? WHERE id = ?`,
|
||
[discount_percent, discountAmount, totalPrice, bookingId], (err) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
logHistory(bookingId, req.user.id, req.user.login, 'discount_percent', oldValue.toString(), discount_percent.toString());
|
||
db.get(`SELECT b.*, p.code as promocode_code FROM bookings b LEFT JOIN promocodes p ON b.promocode_id = p.id WHERE b.id = ?`, [bookingId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json({ message: 'Discount updated', booking: row });
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
app.get('/api/admin/bookings/:id/history', authenticateToken, (req, res) => {
|
||
const bookingId = parseInt(req.params.id);
|
||
db.all(`SELECT id, booking_id, user_id, user_login, field, old_value, new_value, created_at
|
||
FROM booking_history WHERE booking_id = ? ORDER BY created_at DESC`, [bookingId], (err, rows) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json(rows);
|
||
});
|
||
});
|
||
|
||
app.get('/api/bookings', (req, res) => {
|
||
const providedKey = req.headers['x-api-key'];
|
||
if (!providedKey || providedKey !== API_KEY) {
|
||
return res.status(401).json({ error: 'Invalid or missing API key' });
|
||
}
|
||
db.all(`SELECT b.*, p.code as promocode_code FROM bookings b
|
||
LEFT JOIN promocodes p ON b.promocode_id = p.id
|
||
ORDER BY b.checkin_date ASC`, (err, rows) => {
|
||
if (err) {
|
||
console.error(err);
|
||
return res.status(500).json({ error: 'Database error' });
|
||
}
|
||
res.json(rows);
|
||
});
|
||
});
|
||
|
||
app.post('/api/promocodes/validate', (req, res) => {
|
||
const { code, room_type, checkin, checkout } = req.body;
|
||
if (!code) return res.status(400).json({ error: 'Promocode required' });
|
||
validatePromocode(code, (err, promo) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!promo) return res.status(404).json({ error: 'Invalid or expired promocode' });
|
||
const basePrice = calculateBasePrice(room_type, checkin, checkout);
|
||
const discountAmount = Math.round(basePrice * promo.discount_percent / 100);
|
||
const totalPrice = basePrice - discountAmount;
|
||
res.json({
|
||
valid: true,
|
||
discount_percent: promo.discount_percent,
|
||
base_price: basePrice,
|
||
discount_amount: discountAmount,
|
||
total_price: totalPrice,
|
||
code: promo.code
|
||
});
|
||
});
|
||
});
|
||
|
||
// Public rooms endpoint
|
||
app.get('/api/rooms', (req, res) => {
|
||
db.all(`SELECT * FROM rooms WHERE is_active = 1 ORDER BY price_per_guest ASC`, [], (err, rows) => {
|
||
if (err) { console.error('Rooms API error:', err); return res.status(500).json({ error: 'Database error' }); }
|
||
res.json(rows);
|
||
});
|
||
});
|
||
|
||
app.get('/api/admin/promocodes', authenticateToken, requireAdmin, (req, res) => {
|
||
db.all(`SELECT * FROM promocodes ORDER BY created_at DESC`, [], (err, rows) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json(rows);
|
||
});
|
||
});
|
||
|
||
app.post('/api/admin/promocodes', authenticateToken, requireAdmin, (req, res) => {
|
||
const { code, discount_percent, valid_from, valid_to, valid_days, is_active } = req.body;
|
||
if (!code || !discount_percent) return res.status(400).json({ error: 'Code and discount percent required' });
|
||
if (discount_percent < 1 || discount_percent > 99) return res.status(400).json({ error: 'Discount must be between 1 and 99' });
|
||
db.run(`INSERT INTO promocodes (code, discount_percent, valid_from, valid_to, valid_days, is_active)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
[code, discount_percent, valid_from || null, valid_to || null, valid_days || null, is_active !== undefined ? is_active : 1], function(err) {
|
||
if (err) {
|
||
if (err.message.includes('UNIQUE constraint')) return res.status(409).json({ error: 'Promocode already exists' });
|
||
return res.status(500).json({ error: 'Database error' });
|
||
}
|
||
db.get(`SELECT * FROM promocodes WHERE id = ?`, [this.lastID], (err, row) => {
|
||
res.status(201).json({ message: 'Promocode created', promocode: row });
|
||
});
|
||
});
|
||
});
|
||
|
||
app.put('/api/admin/promocodes/:id', authenticateToken, requireAdmin, (req, res) => {
|
||
const promoId = parseInt(req.params.id);
|
||
const { code, discount_percent, valid_from, valid_to, valid_days, is_active } = req.body;
|
||
if (discount_percent !== undefined && (discount_percent < 1 || discount_percent > 99)) {
|
||
return res.status(400).json({ error: 'Discount must be between 1 and 99' });
|
||
}
|
||
db.get(`SELECT id FROM promocodes WHERE id = ?`, [promoId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!row) return res.status(404).json({ error: 'Promocode not found' });
|
||
let fields = [];
|
||
let values = [];
|
||
if (code !== undefined) { fields.push('code = ?'); values.push(code); }
|
||
if (discount_percent !== undefined) { fields.push('discount_percent = ?'); values.push(discount_percent); }
|
||
if (valid_from !== undefined) { fields.push('valid_from = ?'); values.push(valid_from || null); }
|
||
if (valid_to !== undefined) { fields.push('valid_to = ?'); values.push(valid_to || null); }
|
||
if (valid_days !== undefined) { fields.push('valid_days = ?'); values.push(valid_days || null); }
|
||
if (is_active !== undefined) { fields.push('is_active = ?'); values.push(is_active); }
|
||
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||
values.push(promoId);
|
||
db.run(`UPDATE promocodes SET ${fields.join(', ')} WHERE id = ?`, values, (err) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
db.get(`SELECT * FROM promocodes WHERE id = ?`, [promoId], (err, row) => {
|
||
res.json({ message: 'Promocode updated', promocode: row });
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
app.delete('/api/admin/promocodes/:id', authenticateToken, requireAdmin, (req, res) => {
|
||
const promoId = parseInt(req.params.id);
|
||
db.get(`SELECT id FROM promocodes WHERE id = ?`, [promoId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!row) return res.status(404).json({ error: 'Promocode not found' });
|
||
db.run(`DELETE FROM promocodes WHERE id = ?`, [promoId], (err) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json({ message: 'Promocode deleted' });
|
||
});
|
||
});
|
||
});
|
||
|
||
app.post('/api/auth/login', (req, res) => {
|
||
const { login, password } = req.body;
|
||
if (!login || !password) return res.status(400).json({ error: 'Login and password required' });
|
||
db.get(`SELECT id, login, password_hash, full_name, email, role FROM users WHERE login = ?`, [login], (err, user) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||
const match = bcrypt.compareSync(password, user.password_hash);
|
||
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
||
const token = jwt.sign({ id: user.id, login: user.login, role: user.role }, JWT_SECRET, { expiresIn: '24h' });
|
||
res.json({
|
||
token,
|
||
user: { id: user.id, login: user.login, full_name: user.full_name, email: user.email, role: user.role }
|
||
});
|
||
});
|
||
});
|
||
|
||
app.put('/api/auth/me', authenticateToken, (req, res) => {
|
||
const { full_name, email } = req.body;
|
||
db.run(`UPDATE users SET full_name = COALESCE(?, full_name), email = COALESCE(?, email) WHERE id = ?`,
|
||
[full_name || null, email || null, req.user.id], function(err) {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
db.get(`SELECT id, login, full_name, email, role FROM users WHERE id = ?`, [req.user.id], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json({ message: 'Profile updated', user: row });
|
||
});
|
||
});
|
||
});
|
||
|
||
app.post('/api/auth/change-password', authenticateToken, (req, res) => {
|
||
const { current_password, new_password } = req.body;
|
||
if (!current_password || !new_password) return res.status(400).json({ error: 'Current and new password required' });
|
||
if (new_password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' });
|
||
db.get(`SELECT password_hash FROM users WHERE id = ?`, [req.user.id], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!row) return res.status(404).json({ error: 'User not found' });
|
||
const match = bcrypt.compareSync(current_password, row.password_hash);
|
||
if (!match) return res.status(401).json({ error: 'Current password is incorrect' });
|
||
const hash = bcrypt.hashSync(new_password, 10);
|
||
db.run(`UPDATE users SET password_hash = ? WHERE id = ?`, [hash, req.user.id], (err) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json({ message: 'Password changed successfully' });
|
||
});
|
||
});
|
||
});
|
||
|
||
app.get('/api/admin/users', authenticateToken, requireAdmin, (req, res) => {
|
||
db.all(`SELECT id, login, full_name, email, role, created_at FROM users ORDER BY created_at DESC`, [], (err, rows) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json(rows);
|
||
});
|
||
});
|
||
|
||
app.post('/api/admin/users', authenticateToken, requireAdmin, (req, res) => {
|
||
const { login, password, full_name, email, role } = req.body;
|
||
if (!login || !password) return res.status(400).json({ error: 'Login and password required' });
|
||
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Role must be "admin" or "user"' });
|
||
if (password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' });
|
||
const hash = bcrypt.hashSync(password, 10);
|
||
db.run(`INSERT INTO users (login, password_hash, full_name, email, role) VALUES (?, ?, ?, ?, ?)`,
|
||
[login, hash, full_name || null, email || null, role], function(err) {
|
||
if (err) {
|
||
if (err.message.includes('UNIQUE constraint')) return res.status(409).json({ error: 'Login already exists' });
|
||
return res.status(500).json({ error: 'Database error' });
|
||
}
|
||
db.get(`SELECT id, login, full_name, email, role, created_at FROM users WHERE id = ?`, [this.lastID], (err, row) => {
|
||
res.status(201).json({ message: 'User created', user: row });
|
||
});
|
||
});
|
||
});
|
||
|
||
app.put('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => {
|
||
const userId = parseInt(req.params.id);
|
||
const { full_name, email, role, password } = req.body;
|
||
if (role && !['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Role must be "admin" or "user"' });
|
||
db.get(`SELECT id FROM users WHERE id = ?`, [userId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!row) return res.status(404).json({ error: 'User not found' });
|
||
let fields = [];
|
||
let values = [];
|
||
if (full_name !== undefined) { fields.push('full_name = ?'); values.push(full_name || null); }
|
||
if (email !== undefined) { fields.push('email = ?'); values.push(email || null); }
|
||
if (role !== undefined) { fields.push('role = ?'); values.push(role); }
|
||
if (password) {
|
||
if (password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' });
|
||
fields.push('password_hash = ?');
|
||
values.push(bcrypt.hashSync(password, 10));
|
||
}
|
||
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||
values.push(userId);
|
||
db.run(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`, values, (err) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
db.get(`SELECT id, login, full_name, email, role, created_at FROM users WHERE id = ?`, [userId], (err, row) => {
|
||
res.json({ message: 'User updated', user: row });
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
app.delete('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => {
|
||
const userId = parseInt(req.params.id);
|
||
if (userId === req.user.id) return res.status(400).json({ error: 'Cannot delete yourself' });
|
||
db.get(`SELECT login FROM users WHERE id = ?`, [userId], (err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
if (!row) return res.status(404).json({ error: 'User not found' });
|
||
if (row.login === ADMIN_LOGIN) return res.status(403).json({ error: 'Cannot delete superadmin defined in .env' });
|
||
db.run(`DELETE FROM users WHERE id = ?`, [userId], (err) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
res.json({ message: 'User deleted' });
|
||
});
|
||
});
|
||
});
|
||
|
||
app.get('/', (req, res) => {
|
||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||
});
|
||
|
||
app.get('/admin', (req, res) => {
|
||
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
||
});
|
||
|
||
convertImages().then(() => {
|
||
app.listen(PORT, () => {
|
||
console.log('✅ HOTEL777KEY is', API_KEY);
|
||
console.log(`✅ Hotel 777 server running on http://localhost:${PORT}`);
|
||
});
|
||
});
|