Initial commit: CMS backend for Old Vine Hotel
- Complete Express.js API server - MongoDB integration with Mongoose - Admin authentication and authorization - Room management (CRUD operations) - Booking management system - Guest management - Payment processing (Stripe integration) - Content management (pages, blog, gallery) - Media upload and management - Integration services (Booking.com, Expedia, Opera PMS, Trip.com) - Email notifications - Comprehensive logging and error handling
This commit is contained in:
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Test files
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Runtime files
|
||||
*.tgz
|
||||
.npm/
|
||||
.yarn/
|
||||
tmp/
|
||||
.cache/
|
||||
|
||||
# Diagnostic reports
|
||||
.report/
|
||||
|
||||
146
index.js
Normal file
146
index.js
Normal file
@@ -0,0 +1,146 @@
|
||||
const express = require('express');
|
||||
const mongoose = require('mongoose');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const compression = require('compression');
|
||||
const morgan = require('morgan');
|
||||
require('dotenv').config();
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./routes/auth');
|
||||
const roomRoutes = require('./routes/rooms');
|
||||
const bookingRoutes = require('./routes/bookings');
|
||||
const guestRoutes = require('./routes/guests');
|
||||
const paymentRoutes = require('./routes/payments');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const integrationRoutes = require('./routes/integrations');
|
||||
const contactRoutes = require('./routes/contact');
|
||||
const galleryRoutes = require('./routes/gallery');
|
||||
const contentRoutes = require('./routes/content');
|
||||
const blogRoutes = require('./routes/blog');
|
||||
const mediaRoutes = require('./routes/media');
|
||||
const settingsRoutes = require('./routes/settings');
|
||||
const uploadRoutes = require('./routes/upload');
|
||||
const roomCategoryRoutes = require('./routes/roomCategories');
|
||||
const galleryCategoryRoutes = require('./routes/galleryCategories');
|
||||
|
||||
// Import middleware
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
const logger = require('./utils/logger');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5080;
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(compression());
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 requests per windowMs
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
});
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// CORS configuration
|
||||
app.use(cors({
|
||||
origin: process.env.CLIENT_URL || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Logging
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.use(morgan('combined', { stream: { write: message => logger.info(message.trim()) } }));
|
||||
}
|
||||
|
||||
// Database connection
|
||||
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/oldvinehotel', {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
})
|
||||
.then(() => {
|
||||
logger.info('Connected to MongoDB');
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('MongoDB connection error:', error);
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
version: process.env.npm_package_version || '1.0.0'
|
||||
});
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/rooms', roomRoutes);
|
||||
app.use('/api/room-categories', roomCategoryRoutes);
|
||||
app.use('/api/gallery-categories', galleryCategoryRoutes);
|
||||
app.use('/api/bookings', bookingRoutes);
|
||||
app.use('/api/guests', guestRoutes);
|
||||
app.use('/api/payments', paymentRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/integrations', integrationRoutes);
|
||||
app.use('/api/contact', contactRoutes);
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/content', contentRoutes);
|
||||
app.use('/api/blog', blogRoutes);
|
||||
app.use('/api/media', mediaRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
|
||||
// Welcome route
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
message: 'Welcome to The Old Vine Hotel API',
|
||||
version: '1.0.0',
|
||||
documentation: '/api/docs',
|
||||
endpoints: {
|
||||
rooms: '/api/rooms',
|
||||
bookings: '/api/bookings',
|
||||
auth: '/api/auth',
|
||||
payments: '/api/payments',
|
||||
integrations: '/api/integrations'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling middleware (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
// 404 handler
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Route not found',
|
||||
path: req.originalUrl
|
||||
});
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
mongoose.connection.close(() => {
|
||||
logger.info('Database connection closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = app;
|
||||
68
middleware/adminAuth.js
Normal file
68
middleware/adminAuth.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Admin = require('../models/Admin'); // We'll create this model
|
||||
|
||||
const adminAuth = async (req, res, next) => {
|
||||
try {
|
||||
// Get token from header
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Access denied. Admin authentication required.'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Check if the token indicates admin user
|
||||
if (!decoded.isAdmin) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Access denied. Admin privileges required.'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if role is one of the allowed admin roles
|
||||
const allowedRoles = ['super-admin', 'admin', 'editor', 'manager'];
|
||||
if (!allowedRoles.includes(decoded.role)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Access denied. Insufficient privileges.'
|
||||
});
|
||||
}
|
||||
|
||||
// Add admin info to request object
|
||||
req.admin = {
|
||||
id: decoded.id,
|
||||
email: decoded.email,
|
||||
role: decoded.role
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Admin authentication error:', error.message);
|
||||
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid admin token'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Admin token expired'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error during admin authentication'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = adminAuth;
|
||||
56
middleware/auth.js
Normal file
56
middleware/auth.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Guest = require('../models/Guest');
|
||||
|
||||
const auth = async (req, res, next) => {
|
||||
try {
|
||||
// Get token from header
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Access denied. No token provided.'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Get guest from database
|
||||
const guest = await Guest.findById(decoded.id).select('-password');
|
||||
|
||||
if (!guest || !guest.isActive) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token is not valid or account is inactive'
|
||||
});
|
||||
}
|
||||
|
||||
// Add guest to request object
|
||||
req.guest = guest;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error.message);
|
||||
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid token'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token expired'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error during authentication'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = auth;
|
||||
76
middleware/errorHandler.js
Normal file
76
middleware/errorHandler.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
let error = { ...err };
|
||||
error.message = err.message;
|
||||
|
||||
// Log error
|
||||
logger.logError(err, {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
});
|
||||
|
||||
// Mongoose bad ObjectId
|
||||
if (err.name === 'CastError') {
|
||||
const message = 'Invalid ID format';
|
||||
error = {
|
||||
message,
|
||||
statusCode: 400
|
||||
};
|
||||
}
|
||||
|
||||
// Mongoose duplicate key
|
||||
if (err.code === 11000) {
|
||||
const field = Object.keys(err.keyValue)[0];
|
||||
const message = `${field} already exists`;
|
||||
error = {
|
||||
message,
|
||||
statusCode: 400
|
||||
};
|
||||
}
|
||||
|
||||
// Mongoose validation error
|
||||
if (err.name === 'ValidationError') {
|
||||
const message = Object.values(err.errors).map(val => val.message).join(', ');
|
||||
error = {
|
||||
message,
|
||||
statusCode: 400
|
||||
};
|
||||
}
|
||||
|
||||
// JWT errors
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
const message = 'Invalid token';
|
||||
error = {
|
||||
message,
|
||||
statusCode: 401
|
||||
};
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
const message = 'Token expired';
|
||||
error = {
|
||||
message,
|
||||
statusCode: 401
|
||||
};
|
||||
}
|
||||
|
||||
// Stripe errors
|
||||
if (err.type && err.type.startsWith('Stripe')) {
|
||||
const message = 'Payment processing error';
|
||||
error = {
|
||||
message,
|
||||
statusCode: 400
|
||||
};
|
||||
}
|
||||
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
message: error.message || 'Server Error',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = errorHandler;
|
||||
212
models/Admin.js
Normal file
212
models/Admin.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const adminSchema = new mongoose.Schema({
|
||||
// Basic information
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
lowercase: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
lowercase: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 8
|
||||
},
|
||||
|
||||
// Profile information
|
||||
firstName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
lastName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
avatar: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// Role and permissions
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['admin', 'super-admin', 'editor', 'manager'],
|
||||
default: 'admin'
|
||||
},
|
||||
permissions: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'manage_content', 'manage_rooms', 'manage_bookings',
|
||||
'manage_users', 'manage_blog', 'manage_gallery',
|
||||
'manage_settings', 'view_analytics', 'manage_admins'
|
||||
]
|
||||
}],
|
||||
|
||||
// Status
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isSuperAdmin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Security
|
||||
lastLogin: Date,
|
||||
loginAttempts: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lockUntil: Date,
|
||||
passwordResetToken: String,
|
||||
passwordResetExpires: Date,
|
||||
|
||||
// Session tracking
|
||||
currentSessions: [{
|
||||
token: String,
|
||||
createdAt: Date,
|
||||
expiresAt: Date,
|
||||
ipAddress: String,
|
||||
userAgent: String
|
||||
}]
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
transform: function(doc, ret) {
|
||||
delete ret.password;
|
||||
delete ret.passwordResetToken;
|
||||
delete ret.currentSessions;
|
||||
return ret;
|
||||
}
|
||||
},
|
||||
toObject: {
|
||||
virtuals: true,
|
||||
transform: function(doc, ret) {
|
||||
delete ret.password;
|
||||
delete ret.passwordResetToken;
|
||||
delete ret.currentSessions;
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Indexes
|
||||
adminSchema.index({ username: 1 });
|
||||
adminSchema.index({ email: 1 });
|
||||
adminSchema.index({ role: 1 });
|
||||
|
||||
// Virtual for full name
|
||||
adminSchema.virtual('fullName').get(function() {
|
||||
return `${this.firstName} ${this.lastName}`;
|
||||
});
|
||||
|
||||
// Virtual for account locked status
|
||||
adminSchema.virtual('isLocked').get(function() {
|
||||
return !!(this.lockUntil && this.lockUntil > Date.now());
|
||||
});
|
||||
|
||||
// Pre-save middleware to hash password
|
||||
adminSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Method to compare password
|
||||
adminSchema.methods.comparePassword = async function(candidatePassword) {
|
||||
try {
|
||||
return await bcrypt.compare(candidatePassword, this.password);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Method to increment login attempts
|
||||
adminSchema.methods.incLoginAttempts = function() {
|
||||
// If we have a previous lock that has expired, restart at 1
|
||||
if (this.lockUntil && this.lockUntil < Date.now()) {
|
||||
return this.updateOne({
|
||||
$set: { loginAttempts: 1 },
|
||||
$unset: { lockUntil: 1 }
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise increment
|
||||
const updates = { $inc: { loginAttempts: 1 } };
|
||||
|
||||
// Lock the account after 5 attempts for 2 hours
|
||||
const needsLock = this.loginAttempts + 1 >= 5 && !this.isLocked;
|
||||
if (needsLock) {
|
||||
updates.$set = { lockUntil: Date.now() + 2 * 60 * 60 * 1000 };
|
||||
}
|
||||
|
||||
return this.updateOne(updates);
|
||||
};
|
||||
|
||||
// Method to reset login attempts
|
||||
adminSchema.methods.resetLoginAttempts = function() {
|
||||
return this.updateOne({
|
||||
$set: { loginAttempts: 0 },
|
||||
$unset: { lockUntil: 1 }
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to find by credentials
|
||||
adminSchema.statics.findByCredentials = async function(username, password) {
|
||||
const admin = await this.findOne({
|
||||
$or: [{ username }, { email: username }],
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (admin.isLocked) {
|
||||
throw new Error('Account is temporarily locked. Please try again later.');
|
||||
}
|
||||
|
||||
const isMatch = await admin.comparePassword(password);
|
||||
|
||||
if (!isMatch) {
|
||||
await admin.incLoginAttempts();
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
// Reset login attempts on successful login
|
||||
if (admin.loginAttempts > 0) {
|
||||
await admin.resetLoginAttempts();
|
||||
}
|
||||
|
||||
// Update last login
|
||||
admin.lastLogin = new Date();
|
||||
await admin.save();
|
||||
|
||||
return admin;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Admin', adminSchema);
|
||||
|
||||
203
models/BlogPost.js
Normal file
203
models/BlogPost.js
Normal file
@@ -0,0 +1,203 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const blogPostSchema = new mongoose.Schema({
|
||||
// Post information
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true
|
||||
},
|
||||
excerpt: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 300
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Media
|
||||
featuredImage: {
|
||||
url: String,
|
||||
alt: String,
|
||||
caption: String
|
||||
},
|
||||
images: [{
|
||||
url: String,
|
||||
alt: String,
|
||||
caption: String
|
||||
}],
|
||||
|
||||
// Categorization
|
||||
category: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: [
|
||||
'News', 'Events', 'Travel Tips', 'Local Attractions',
|
||||
'Hotel Updates', 'Food & Dining', 'Spa & Wellness',
|
||||
'Special Offers', 'Guest Stories', 'Behind the Scenes'
|
||||
]
|
||||
},
|
||||
tags: [String],
|
||||
|
||||
// Author
|
||||
author: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin',
|
||||
required: true
|
||||
},
|
||||
|
||||
// Publishing
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'published', 'archived'],
|
||||
default: 'draft'
|
||||
},
|
||||
publishedAt: Date,
|
||||
scheduledPublishAt: Date,
|
||||
|
||||
// Engagement
|
||||
views: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
likes: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// SEO
|
||||
seo: {
|
||||
title: String,
|
||||
description: String,
|
||||
keywords: [String],
|
||||
ogImage: String,
|
||||
canonicalUrl: String
|
||||
},
|
||||
|
||||
// Features
|
||||
isFeatured: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowComments: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// Related content
|
||||
relatedPosts: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'BlogPost'
|
||||
}]
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
blogPostSchema.index({ slug: 1 });
|
||||
blogPostSchema.index({ status: 1, publishedAt: -1 });
|
||||
blogPostSchema.index({ category: 1 });
|
||||
blogPostSchema.index({ tags: 1 });
|
||||
blogPostSchema.index({ author: 1 });
|
||||
blogPostSchema.index({ isFeatured: 1 });
|
||||
|
||||
// Virtual for reading time (based on word count)
|
||||
blogPostSchema.virtual('readingTime').get(function() {
|
||||
const wordsPerMinute = 200;
|
||||
const wordCount = this.content.split(/\s+/).length;
|
||||
const minutes = Math.ceil(wordCount / wordsPerMinute);
|
||||
return minutes;
|
||||
});
|
||||
|
||||
// Virtual for is published
|
||||
blogPostSchema.virtual('isPublished').get(function() {
|
||||
return this.status === 'published' && this.publishedAt && this.publishedAt <= new Date();
|
||||
});
|
||||
|
||||
// Pre-save middleware to generate slug and handle publishing
|
||||
blogPostSchema.pre('save', function(next) {
|
||||
// Generate slug if modified or new
|
||||
if (this.isModified('title') || !this.slug) {
|
||||
this.slug = this.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
// Set published date when status changes to published
|
||||
if (this.isModified('status') && this.status === 'published' && !this.publishedAt) {
|
||||
this.publishedAt = new Date();
|
||||
}
|
||||
|
||||
// Generate SEO fields from content if not set
|
||||
if (!this.seo.title) {
|
||||
this.seo.title = this.title;
|
||||
}
|
||||
if (!this.seo.description) {
|
||||
this.seo.description = this.excerpt;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Static method to get published posts
|
||||
blogPostSchema.statics.getPublished = function(options = {}) {
|
||||
const {
|
||||
category,
|
||||
tag,
|
||||
limit = 10,
|
||||
skip = 0,
|
||||
featured = false
|
||||
} = options;
|
||||
|
||||
const query = {
|
||||
status: 'published',
|
||||
publishedAt: { $lte: new Date() }
|
||||
};
|
||||
|
||||
if (category) query.category = category;
|
||||
if (tag) query.tags = tag;
|
||||
if (featured) query.isFeatured = true;
|
||||
|
||||
return this.find(query)
|
||||
.populate('author', 'firstName lastName avatar')
|
||||
.sort({ publishedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
// Static method to increment views
|
||||
blogPostSchema.statics.incrementViews = function(postId) {
|
||||
return this.findByIdAndUpdate(postId, { $inc: { views: 1 } });
|
||||
};
|
||||
|
||||
// Instance method to get related posts
|
||||
blogPostSchema.methods.getRelatedPosts = async function(limit = 3) {
|
||||
return this.model('BlogPost').find({
|
||||
_id: { $ne: this._id },
|
||||
status: 'published',
|
||||
publishedAt: { $lte: new Date() },
|
||||
$or: [
|
||||
{ category: this.category },
|
||||
{ tags: { $in: this.tags } }
|
||||
]
|
||||
})
|
||||
.populate('author', 'firstName lastName avatar')
|
||||
.sort({ publishedAt: -1 })
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('BlogPost', blogPostSchema);
|
||||
|
||||
290
models/Booking.js
Normal file
290
models/Booking.js
Normal file
@@ -0,0 +1,290 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const bookingSchema = new mongoose.Schema({
|
||||
// Booking identification
|
||||
bookingNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
confirmationCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
|
||||
// Guest information
|
||||
guest: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Guest',
|
||||
required: true
|
||||
},
|
||||
|
||||
// Room and dates
|
||||
room: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Room',
|
||||
required: true
|
||||
},
|
||||
checkInDate: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
checkOutDate: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Guest details
|
||||
numberOfGuests: {
|
||||
adults: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1
|
||||
},
|
||||
children: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0
|
||||
}
|
||||
},
|
||||
|
||||
// Pricing
|
||||
roomRate: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
numberOfNights: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
subtotal: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
taxes: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
fees: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
discounts: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
totalAmount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Payment information
|
||||
paymentStatus: {
|
||||
type: String,
|
||||
enum: ['Pending', 'Paid', 'Partially Paid', 'Refunded', 'Failed'],
|
||||
default: 'Pending'
|
||||
},
|
||||
paymentMethod: {
|
||||
type: String,
|
||||
enum: ['Credit Card', 'Debit Card', 'Bank Transfer', 'Cash', 'Online Payment']
|
||||
},
|
||||
stripePaymentIntentId: String,
|
||||
|
||||
// Booking status
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['Pending', 'Confirmed', 'Checked In', 'Checked Out', 'Cancelled', 'No Show'],
|
||||
default: 'Pending'
|
||||
},
|
||||
|
||||
// Special requests and notes
|
||||
specialRequests: {
|
||||
type: String,
|
||||
maxlength: 1000
|
||||
},
|
||||
internalNotes: {
|
||||
type: String,
|
||||
maxlength: 1000
|
||||
},
|
||||
|
||||
// Check-in/out details
|
||||
actualCheckInTime: Date,
|
||||
actualCheckOutTime: Date,
|
||||
earlyCheckIn: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
lateCheckOut: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Booking source
|
||||
bookingSource: {
|
||||
type: String,
|
||||
enum: ['Direct', 'Booking.com', 'Expedia', 'Trip.com', 'Phone', 'Walk-in', 'Travel Agent'],
|
||||
default: 'Direct'
|
||||
},
|
||||
|
||||
// External system IDs
|
||||
operaBookingId: String,
|
||||
externalBookingId: String, // ID from booking platforms
|
||||
|
||||
// Cancellation
|
||||
cancellationReason: String,
|
||||
cancellationDate: Date,
|
||||
cancellationFee: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
refundAmount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// Communication
|
||||
emailConfirmationSent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
smsConfirmationSent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Additional services
|
||||
addOns: [{
|
||||
service: String,
|
||||
description: String,
|
||||
quantity: Number,
|
||||
unitPrice: Number,
|
||||
totalPrice: Number
|
||||
}],
|
||||
|
||||
// Group booking
|
||||
isGroupBooking: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
groupSize: Number,
|
||||
groupLeader: String,
|
||||
|
||||
// Loyalty program
|
||||
loyaltyPointsEarned: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
loyaltyPointsRedeemed: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
bookingSchema.index({ bookingNumber: 1 });
|
||||
bookingSchema.index({ confirmationCode: 1 });
|
||||
bookingSchema.index({ guest: 1 });
|
||||
bookingSchema.index({ room: 1 });
|
||||
bookingSchema.index({ checkInDate: 1, checkOutDate: 1 });
|
||||
bookingSchema.index({ status: 1 });
|
||||
bookingSchema.index({ bookingSource: 1 });
|
||||
bookingSchema.index({ operaBookingId: 1 });
|
||||
bookingSchema.index({ createdAt: -1 });
|
||||
|
||||
// Virtual for booking duration
|
||||
bookingSchema.virtual('duration').get(function() {
|
||||
if (this.checkInDate && this.checkOutDate) {
|
||||
const diffTime = Math.abs(this.checkOutDate - this.checkInDate);
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Pre-save middleware to generate booking number and confirmation code
|
||||
bookingSchema.pre('save', function(next) {
|
||||
if (!this.bookingNumber) {
|
||||
// Generate booking number: OVH + year + random 6 digits
|
||||
const year = new Date().getFullYear();
|
||||
const random = Math.floor(100000 + Math.random() * 900000);
|
||||
this.bookingNumber = `OVH${year}${random}`;
|
||||
}
|
||||
|
||||
if (!this.confirmationCode) {
|
||||
// Generate confirmation code: 8 character alphanumeric
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
this.confirmationCode = code;
|
||||
}
|
||||
|
||||
// Calculate number of nights if not set
|
||||
if (!this.numberOfNights && this.checkInDate && this.checkOutDate) {
|
||||
const diffTime = Math.abs(this.checkOutDate - this.checkInDate);
|
||||
this.numberOfNights = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Static method to generate revenue reports
|
||||
bookingSchema.statics.generateRevenueReport = function(startDate, endDate) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
status: { $in: ['Confirmed', 'Checked In', 'Checked Out'] },
|
||||
checkInDate: { $gte: startDate, $lte: endDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
year: { $year: '$checkInDate' },
|
||||
month: { $month: '$checkInDate' },
|
||||
day: { $dayOfMonth: '$checkInDate' }
|
||||
},
|
||||
totalRevenue: { $sum: '$totalAmount' },
|
||||
bookingsCount: { $sum: 1 },
|
||||
averageRate: { $avg: '$roomRate' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1 }
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
// Instance method to check if booking can be cancelled
|
||||
bookingSchema.methods.canBeCancelled = function() {
|
||||
const now = new Date();
|
||||
const checkInDate = new Date(this.checkInDate);
|
||||
const hoursUntilCheckIn = (checkInDate - now) / (1000 * 60 * 60);
|
||||
|
||||
return (
|
||||
this.status === 'Confirmed' &&
|
||||
hoursUntilCheckIn > 24 // Can cancel up to 24 hours before check-in
|
||||
);
|
||||
};
|
||||
|
||||
// Instance method to calculate cancellation fee
|
||||
bookingSchema.methods.calculateCancellationFee = function() {
|
||||
const now = new Date();
|
||||
const checkInDate = new Date(this.checkInDate);
|
||||
const hoursUntilCheckIn = (checkInDate - now) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursUntilCheckIn > 48) {
|
||||
return 0; // Free cancellation
|
||||
} else if (hoursUntilCheckIn > 24) {
|
||||
return this.totalAmount * 0.25; // 25% fee
|
||||
} else {
|
||||
return this.totalAmount * 0.50; // 50% fee
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Booking', bookingSchema);
|
||||
90
models/Content.js
Normal file
90
models/Content.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const contentSchema = new mongoose.Schema({
|
||||
// Content identification
|
||||
page: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
enum: ['home', 'about', 'contact', 'rooms', 'facilities', 'gallery']
|
||||
},
|
||||
|
||||
// Hero section
|
||||
hero: {
|
||||
title: String,
|
||||
subtitle: String,
|
||||
description: String,
|
||||
backgroundImage: String,
|
||||
ctaText: String,
|
||||
ctaLink: String
|
||||
},
|
||||
|
||||
// Page sections (flexible structure for different pages)
|
||||
sections: [{
|
||||
sectionId: String, // e.g., 'welcome', 'features', 'testimonials'
|
||||
title: String,
|
||||
subtitle: String,
|
||||
content: String,
|
||||
image: String,
|
||||
order: Number,
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// Additional fields for different section types
|
||||
items: [mongoose.Schema.Types.Mixed], // For lists, features, etc.
|
||||
backgroundImage: String,
|
||||
backgroundVideo: String,
|
||||
layout: String // 'left-image', 'right-image', 'full-width', etc.
|
||||
}],
|
||||
|
||||
// Meta information for SEO
|
||||
seo: {
|
||||
title: String,
|
||||
description: String,
|
||||
keywords: [String],
|
||||
ogImage: String,
|
||||
canonicalUrl: String
|
||||
},
|
||||
|
||||
// Version control
|
||||
version: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
isPublished: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
publishedAt: Date,
|
||||
|
||||
// Audit trail
|
||||
lastModifiedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin'
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
contentSchema.index({ page: 1 });
|
||||
contentSchema.index({ isPublished: 1 });
|
||||
|
||||
// Pre-save middleware to update version and publish date
|
||||
contentSchema.pre('save', function(next) {
|
||||
if (this.isModified() && !this.isNew) {
|
||||
this.version += 1;
|
||||
}
|
||||
|
||||
if (this.isModified('isPublished') && this.isPublished) {
|
||||
this.publishedAt = new Date();
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Content', contentSchema);
|
||||
|
||||
93
models/GalleryCategory.js
Normal file
93
models/GalleryCategory.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const galleryCategorySchema = new mongoose.Schema({
|
||||
// Basic category information
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
unique: true
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
shortDescription: {
|
||||
type: String,
|
||||
maxlength: 200
|
||||
},
|
||||
|
||||
// Category images (gallery)
|
||||
images: [{
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isPrimary: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}],
|
||||
|
||||
// Display settings
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
displayOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// SEO and metadata
|
||||
metaTitle: String,
|
||||
metaDescription: String
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
galleryCategorySchema.index({ slug: 1 });
|
||||
galleryCategorySchema.index({ isActive: 1, displayOrder: 1 });
|
||||
|
||||
// Pre-save middleware to generate slug
|
||||
galleryCategorySchema.pre('save', function(next) {
|
||||
if (this.isModified('name') || !this.slug) {
|
||||
this.slug = this.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Virtual for primary image
|
||||
galleryCategorySchema.virtual('primaryImage').get(function() {
|
||||
const primary = this.images.find(img => img.isPrimary);
|
||||
return primary ? primary.url : (this.images.length > 0 ? this.images[0].url : null);
|
||||
});
|
||||
|
||||
// Virtual for image count
|
||||
galleryCategorySchema.virtual('imageCount').get(function() {
|
||||
return this.images.length;
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('GalleryCategory', galleryCategorySchema);
|
||||
|
||||
338
models/Guest.js
Normal file
338
models/Guest.js
Normal file
@@ -0,0 +1,338 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const guestSchema = new mongoose.Schema({
|
||||
// Personal information
|
||||
firstName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
lastName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true,
|
||||
trim: true,
|
||||
match: [/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/, 'Please enter a valid email']
|
||||
},
|
||||
phone: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// Authentication
|
||||
password: {
|
||||
type: String,
|
||||
minlength: 6,
|
||||
select: false // Don't include in queries by default
|
||||
},
|
||||
isRegistered: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Address information
|
||||
address: {
|
||||
street: String,
|
||||
city: String,
|
||||
state: String,
|
||||
country: String,
|
||||
zipCode: String
|
||||
},
|
||||
|
||||
// Personal details
|
||||
dateOfBirth: Date,
|
||||
nationality: String,
|
||||
gender: {
|
||||
type: String,
|
||||
enum: ['Male', 'Female', 'Other', 'Prefer not to say']
|
||||
},
|
||||
|
||||
// Identification
|
||||
idType: {
|
||||
type: String,
|
||||
enum: ['Passport', 'Driver License', 'National ID', 'Other']
|
||||
},
|
||||
idNumber: String,
|
||||
idExpiryDate: Date,
|
||||
|
||||
// Preferences
|
||||
preferences: {
|
||||
roomType: {
|
||||
type: String,
|
||||
enum: ['Standard', 'Deluxe', 'Suite', 'Executive Suite', 'Presidential Suite']
|
||||
},
|
||||
bedPreference: {
|
||||
type: String,
|
||||
enum: ['Single', 'Double', 'Queen', 'King', 'Twin']
|
||||
},
|
||||
smokingPreference: {
|
||||
type: String,
|
||||
enum: ['Non-smoking', 'Smoking'],
|
||||
default: 'Non-smoking'
|
||||
},
|
||||
floorPreference: {
|
||||
type: String,
|
||||
enum: ['Low', 'High', 'No preference'],
|
||||
default: 'No preference'
|
||||
},
|
||||
viewPreference: {
|
||||
type: String,
|
||||
enum: ['Ocean', 'City', 'Garden', 'Mountain', 'No preference'],
|
||||
default: 'No preference'
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'en'
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: 'USD'
|
||||
}
|
||||
},
|
||||
|
||||
// Special requirements
|
||||
specialRequirements: {
|
||||
accessibility: {
|
||||
wheelchairAccess: { type: Boolean, default: false },
|
||||
hearingImpaired: { type: Boolean, default: false },
|
||||
visuallyImpaired: { type: Boolean, default: false },
|
||||
other: String
|
||||
},
|
||||
dietaryRestrictions: [{
|
||||
type: String,
|
||||
enum: ['Vegetarian', 'Vegan', 'Gluten-free', 'Halal', 'Kosher', 'Diabetic', 'Other']
|
||||
}],
|
||||
allergies: [String],
|
||||
medicalConditions: String
|
||||
},
|
||||
|
||||
// Loyalty program
|
||||
loyaltyProgram: {
|
||||
memberId: String,
|
||||
tier: {
|
||||
type: String,
|
||||
enum: ['Bronze', 'Silver', 'Gold', 'Platinum'],
|
||||
default: 'Bronze'
|
||||
},
|
||||
points: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
joinDate: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
},
|
||||
|
||||
// Communication preferences
|
||||
communicationPreferences: {
|
||||
email: {
|
||||
marketing: { type: Boolean, default: false },
|
||||
bookingUpdates: { type: Boolean, default: true },
|
||||
specialOffers: { type: Boolean, default: false }
|
||||
},
|
||||
sms: {
|
||||
marketing: { type: Boolean, default: false },
|
||||
bookingUpdates: { type: Boolean, default: false },
|
||||
specialOffers: { type: Boolean, default: false }
|
||||
},
|
||||
phone: {
|
||||
marketing: { type: Boolean, default: false },
|
||||
bookingUpdates: { type: Boolean, default: false }
|
||||
}
|
||||
},
|
||||
|
||||
// Guest history
|
||||
totalStays: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
totalSpent: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lastStayDate: Date,
|
||||
averageRating: {
|
||||
type: Number,
|
||||
min: 1,
|
||||
max: 5
|
||||
},
|
||||
|
||||
// VIP status
|
||||
isVIP: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
vipNotes: String,
|
||||
|
||||
// External system IDs
|
||||
operaGuestId: String,
|
||||
externalGuestIds: [{
|
||||
system: String, // 'booking.com', 'expedia', etc.
|
||||
id: String
|
||||
}],
|
||||
|
||||
// Emergency contact
|
||||
emergencyContact: {
|
||||
name: String,
|
||||
relationship: String,
|
||||
phone: String,
|
||||
email: String
|
||||
},
|
||||
|
||||
// Account status
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isBlacklisted: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
blacklistReason: String,
|
||||
|
||||
// Verification
|
||||
emailVerified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
phoneVerified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
emailVerificationToken: String,
|
||||
passwordResetToken: String,
|
||||
passwordResetExpires: Date,
|
||||
|
||||
// Notes and comments
|
||||
internalNotes: String,
|
||||
|
||||
// GDPR compliance
|
||||
dataConsent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
consentDate: Date,
|
||||
|
||||
// Last activity
|
||||
lastLogin: Date,
|
||||
lastActivity: Date
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
guestSchema.index({ email: 1 });
|
||||
guestSchema.index({ phone: 1 });
|
||||
guestSchema.index({ 'loyaltyProgram.memberId': 1 });
|
||||
guestSchema.index({ operaGuestId: 1 });
|
||||
guestSchema.index({ isVIP: 1 });
|
||||
guestSchema.index({ totalStays: -1 });
|
||||
guestSchema.index({ totalSpent: -1 });
|
||||
|
||||
// Virtual for full name
|
||||
guestSchema.virtual('fullName').get(function() {
|
||||
return `${this.firstName} ${this.lastName}`;
|
||||
});
|
||||
|
||||
// Virtual for formatted address
|
||||
guestSchema.virtual('formattedAddress').get(function() {
|
||||
if (!this.address || !this.address.street) return '';
|
||||
|
||||
const { street, city, state, country, zipCode } = this.address;
|
||||
return `${street}, ${city}, ${state} ${zipCode}, ${country}`;
|
||||
});
|
||||
|
||||
// Pre-save middleware to hash password
|
||||
guestSchema.pre('save', async function(next) {
|
||||
// Only hash password if it's modified and exists
|
||||
if (!this.isModified('password') || !this.password) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
// Hash password with cost of 12
|
||||
const hashedPassword = await bcrypt.hash(this.password, 12);
|
||||
this.password = hashedPassword;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Method to check password
|
||||
guestSchema.methods.comparePassword = async function(candidatePassword) {
|
||||
if (!this.password) return false;
|
||||
return bcrypt.compare(candidatePassword, this.password);
|
||||
};
|
||||
|
||||
// Method to update loyalty points
|
||||
guestSchema.methods.addLoyaltyPoints = function(points) {
|
||||
this.loyaltyProgram.points += points;
|
||||
|
||||
// Update tier based on points
|
||||
if (this.loyaltyProgram.points >= 10000) {
|
||||
this.loyaltyProgram.tier = 'Platinum';
|
||||
} else if (this.loyaltyProgram.points >= 5000) {
|
||||
this.loyaltyProgram.tier = 'Gold';
|
||||
} else if (this.loyaltyProgram.points >= 1000) {
|
||||
this.loyaltyProgram.tier = 'Silver';
|
||||
}
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Method to update stay statistics
|
||||
guestSchema.methods.updateStayStats = function(stayAmount) {
|
||||
this.totalStays += 1;
|
||||
this.totalSpent += stayAmount;
|
||||
this.lastStayDate = new Date();
|
||||
|
||||
// Check VIP status
|
||||
if (this.totalStays >= 10 && this.totalSpent >= 5000) {
|
||||
this.isVIP = true;
|
||||
}
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Static method to find VIP guests
|
||||
guestSchema.statics.findVIPGuests = function() {
|
||||
return this.find({ isVIP: true, isActive: true })
|
||||
.sort({ totalSpent: -1 })
|
||||
.select('firstName lastName email phone totalStays totalSpent loyaltyProgram');
|
||||
};
|
||||
|
||||
// Static method to find guests by loyalty tier
|
||||
guestSchema.statics.findByLoyaltyTier = function(tier) {
|
||||
return this.find({
|
||||
'loyaltyProgram.tier': tier,
|
||||
isActive: true
|
||||
}).sort({ 'loyaltyProgram.points': -1 });
|
||||
};
|
||||
|
||||
// Method to generate password reset token
|
||||
guestSchema.methods.createPasswordResetToken = function() {
|
||||
const resetToken = require('crypto').randomBytes(32).toString('hex');
|
||||
|
||||
this.passwordResetToken = require('crypto')
|
||||
.createHash('sha256')
|
||||
.update(resetToken)
|
||||
.digest('hex');
|
||||
|
||||
this.passwordResetExpires = Date.now() + 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
return resetToken;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Guest', guestSchema);
|
||||
153
models/Media.js
Normal file
153
models/Media.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const mediaSchema = new mongoose.Schema({
|
||||
// File information
|
||||
filename: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
originalName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
thumbnailUrl: String,
|
||||
|
||||
// File details
|
||||
mimeType: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Media type
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['image', 'video', 'document', 'other'],
|
||||
required: true
|
||||
},
|
||||
|
||||
// Image specific
|
||||
dimensions: {
|
||||
width: Number,
|
||||
height: Number
|
||||
},
|
||||
|
||||
// Categorization
|
||||
folder: {
|
||||
type: String,
|
||||
default: 'general',
|
||||
enum: ['general', 'rooms', 'gallery', 'blog', 'hero', 'about', 'facilities', 'avatars']
|
||||
},
|
||||
tags: [String],
|
||||
|
||||
// Metadata
|
||||
alt: String,
|
||||
caption: String,
|
||||
description: String,
|
||||
|
||||
// Usage tracking
|
||||
usedIn: [{
|
||||
model: String, // 'Content', 'BlogPost', 'Room', etc.
|
||||
documentId: mongoose.Schema.Types.ObjectId,
|
||||
field: String
|
||||
}],
|
||||
|
||||
// Upload information
|
||||
uploadedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin',
|
||||
required: true
|
||||
},
|
||||
|
||||
// Status
|
||||
isPublic: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
mediaSchema.index({ folder: 1, createdAt: -1 });
|
||||
mediaSchema.index({ type: 1 });
|
||||
mediaSchema.index({ uploadedBy: 1 });
|
||||
mediaSchema.index({ tags: 1 });
|
||||
mediaSchema.index({ filename: 1 });
|
||||
|
||||
// Virtual for file extension
|
||||
mediaSchema.virtual('extension').get(function() {
|
||||
return this.filename.split('.').pop().toLowerCase();
|
||||
});
|
||||
|
||||
// Virtual for is image
|
||||
mediaSchema.virtual('isImage').get(function() {
|
||||
return this.type === 'image';
|
||||
});
|
||||
|
||||
// Virtual for file size in MB
|
||||
mediaSchema.virtual('sizeInMB').get(function() {
|
||||
return (this.size / (1024 * 1024)).toFixed(2);
|
||||
});
|
||||
|
||||
// Static method to get media by folder
|
||||
mediaSchema.statics.getByFolder = function(folder, options = {}) {
|
||||
const { limit = 50, skip = 0, type } = options;
|
||||
|
||||
const query = { folder };
|
||||
if (type) query.type = type;
|
||||
|
||||
return this.find(query)
|
||||
.populate('uploadedBy', 'firstName lastName')
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
// Static method to search media
|
||||
mediaSchema.statics.search = function(searchTerm, options = {}) {
|
||||
const { folder, type, limit = 50 } = options;
|
||||
|
||||
const query = {
|
||||
$or: [
|
||||
{ originalName: new RegExp(searchTerm, 'i') },
|
||||
{ alt: new RegExp(searchTerm, 'i') },
|
||||
{ caption: new RegExp(searchTerm, 'i') },
|
||||
{ tags: new RegExp(searchTerm, 'i') }
|
||||
]
|
||||
};
|
||||
|
||||
if (folder) query.folder = folder;
|
||||
if (type) query.type = type;
|
||||
|
||||
return this.find(query)
|
||||
.populate('uploadedBy', 'firstName lastName')
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
// Instance method to track usage
|
||||
mediaSchema.methods.addUsage = function(model, documentId, field) {
|
||||
this.usedIn.push({ model, documentId, field });
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Instance method to remove usage
|
||||
mediaSchema.methods.removeUsage = function(model, documentId, field) {
|
||||
this.usedIn = this.usedIn.filter(usage =>
|
||||
!(usage.model === model && usage.documentId.equals(documentId) && usage.field === field)
|
||||
);
|
||||
return this.save();
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Media', mediaSchema);
|
||||
|
||||
239
models/Room.js
Normal file
239
models/Room.js
Normal file
@@ -0,0 +1,239 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const roomSchema = new mongoose.Schema({
|
||||
// Basic room information
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['Standard', 'Deluxe', 'Suite', 'Executive Suite', 'Presidential Suite'],
|
||||
},
|
||||
category: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'RoomCategory',
|
||||
required: false // Optional for backward compatibility
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
shortDescription: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 200
|
||||
},
|
||||
|
||||
// Room specifications
|
||||
roomNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
floor: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: Number, // in square meters
|
||||
required: true
|
||||
},
|
||||
maxOccupancy: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 8
|
||||
},
|
||||
bedType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['Single', 'Double', 'Queen', 'King', 'Twin', 'Sofa Bed']
|
||||
},
|
||||
bedCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1
|
||||
},
|
||||
|
||||
// Pricing
|
||||
basePrice: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
seasonalPricing: [{
|
||||
season: String,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
priceMultiplier: Number // 1.2 for 20% increase
|
||||
}],
|
||||
|
||||
// Room features and amenities
|
||||
amenities: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'WiFi', 'TV', 'AC', 'Minibar', 'Safe', 'Balcony', 'Ocean View',
|
||||
'City View', 'Mountain View', 'Garden View', 'Jacuzzi', 'Fireplace',
|
||||
'Kitchen', 'Kitchenette', 'Workspace', 'Butler Service', 'Spa Access',
|
||||
'Private Pool', 'Terrace', 'Walk-in Closet', 'Sound System'
|
||||
]
|
||||
}],
|
||||
|
||||
// Media
|
||||
images: [{
|
||||
url: String,
|
||||
alt: String,
|
||||
isPrimary: { type: Boolean, default: false }
|
||||
}],
|
||||
virtualTour: {
|
||||
url: String,
|
||||
provider: String // '360°', 'Matterport', etc.
|
||||
},
|
||||
|
||||
// Availability and status
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['Available', 'Occupied', 'Out of Order', 'Maintenance'],
|
||||
default: 'Available'
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// Integration IDs for external systems
|
||||
operaRoomId: String, // Opera PMS room ID
|
||||
bookingComRoomId: String,
|
||||
expediaRoomId: String,
|
||||
tripComRoomId: String,
|
||||
|
||||
// SEO and metadata
|
||||
slug: {
|
||||
type: String,
|
||||
unique: true,
|
||||
lowercase: true
|
||||
},
|
||||
metaTitle: String,
|
||||
metaDescription: String,
|
||||
|
||||
// Room policies
|
||||
smokingAllowed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
petsAllowed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Maintenance and housekeeping
|
||||
lastMaintenance: Date,
|
||||
lastCleaning: Date,
|
||||
cleaningStatus: {
|
||||
type: String,
|
||||
enum: ['Clean', 'Dirty', 'In Progress', 'Inspected'],
|
||||
default: 'Clean'
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
roomSchema.index({ roomNumber: 1 });
|
||||
roomSchema.index({ type: 1, status: 1 });
|
||||
roomSchema.index({ slug: 1 });
|
||||
roomSchema.index({ category: 1 });
|
||||
roomSchema.index({ operaRoomId: 1 });
|
||||
|
||||
// Virtual for current price (considering seasonal pricing)
|
||||
roomSchema.virtual('currentPrice').get(function() {
|
||||
const now = new Date();
|
||||
const seasonalRate = this.seasonalPricing.find(pricing =>
|
||||
pricing.startDate <= now && pricing.endDate >= now
|
||||
);
|
||||
|
||||
return seasonalRate
|
||||
? this.basePrice * seasonalRate.priceMultiplier
|
||||
: this.basePrice;
|
||||
});
|
||||
|
||||
// Pre-save middleware to generate slug
|
||||
roomSchema.pre('save', function(next) {
|
||||
if (this.isModified('name') || !this.slug) {
|
||||
this.slug = this.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Static method to find available rooms
|
||||
roomSchema.statics.findAvailable = function(checkIn, checkOut, guests = 1) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
status: 'Available',
|
||||
isActive: true,
|
||||
maxOccupancy: { $gte: guests }
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'bookings',
|
||||
let: { roomId: '$_id' },
|
||||
pipeline: [
|
||||
{
|
||||
$match: {
|
||||
$expr: { $eq: ['$room', '$$roomId'] },
|
||||
status: { $in: ['Confirmed', 'Checked In'] },
|
||||
$or: [
|
||||
{
|
||||
checkInDate: { $lt: checkOut },
|
||||
checkOutDate: { $gt: checkIn }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
as: 'conflictingBookings'
|
||||
}
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
conflictingBookings: { $size: 0 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
conflictingBookings: 0
|
||||
}
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
// Instance method to check availability
|
||||
roomSchema.methods.isAvailable = async function(checkIn, checkOut) {
|
||||
const Booking = mongoose.model('Booking');
|
||||
|
||||
const conflictingBooking = await Booking.findOne({
|
||||
room: this._id,
|
||||
status: { $in: ['Confirmed', 'Checked In'] },
|
||||
$or: [
|
||||
{
|
||||
checkInDate: { $lt: checkOut },
|
||||
checkOutDate: { $gt: checkIn }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return !conflictingBooking && this.status === 'Available' && this.isActive;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Room', roomSchema);
|
||||
116
models/RoomCategory.js
Normal file
116
models/RoomCategory.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const roomCategorySchema = new mongoose.Schema({
|
||||
// Basic category information
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
unique: true
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
shortDescription: {
|
||||
type: String,
|
||||
maxlength: 200
|
||||
},
|
||||
|
||||
// Category images (gallery)
|
||||
images: [{
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isPrimary: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}],
|
||||
|
||||
// Pricing information (range for this category)
|
||||
priceRange: {
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
|
||||
// Category features/amenities (common to all rooms in this category)
|
||||
features: [{
|
||||
type: String
|
||||
}],
|
||||
|
||||
// SEO and metadata
|
||||
metaTitle: String,
|
||||
metaDescription: String,
|
||||
|
||||
// Display settings
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
displayOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// Statistics (calculated)
|
||||
roomCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
roomCategorySchema.index({ slug: 1 });
|
||||
roomCategorySchema.index({ isActive: 1, displayOrder: 1 });
|
||||
|
||||
// Pre-save middleware to generate slug
|
||||
roomCategorySchema.pre('save', function(next) {
|
||||
if (this.isModified('name') || !this.slug) {
|
||||
this.slug = this.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Virtual for primary image
|
||||
roomCategorySchema.virtual('primaryImage').get(function() {
|
||||
const primary = this.images.find(img => img.isPrimary);
|
||||
return primary ? primary.url : (this.images.length > 0 ? this.images[0].url : null);
|
||||
});
|
||||
|
||||
// Virtual for image count
|
||||
roomCategorySchema.virtual('imageCount').get(function() {
|
||||
return this.images.length;
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('RoomCategory', roomCategorySchema);
|
||||
|
||||
190
models/SiteSettings.js
Normal file
190
models/SiteSettings.js
Normal file
@@ -0,0 +1,190 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const siteSettingsSchema = new mongoose.Schema({
|
||||
// Hotel information
|
||||
hotel: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'The Old Vine Hotel'
|
||||
},
|
||||
tagline: String,
|
||||
description: String,
|
||||
logo: String,
|
||||
favicon: String,
|
||||
|
||||
// Contact information
|
||||
phone: String,
|
||||
email: String,
|
||||
whatsapp: String,
|
||||
website: String,
|
||||
|
||||
// Address
|
||||
address: {
|
||||
street: String,
|
||||
city: String,
|
||||
state: String,
|
||||
zipCode: String,
|
||||
country: String,
|
||||
formatted: String,
|
||||
coordinates: {
|
||||
lat: Number,
|
||||
lng: Number
|
||||
}
|
||||
},
|
||||
|
||||
// Social media
|
||||
socialMedia: {
|
||||
facebook: String,
|
||||
instagram: String,
|
||||
twitter: String,
|
||||
linkedin: String,
|
||||
youtube: String,
|
||||
tiktok: String
|
||||
},
|
||||
|
||||
// Business hours
|
||||
businessHours: {
|
||||
checkIn: { type: String, default: '14:00' },
|
||||
checkOut: { type: String, default: '11:00' },
|
||||
reception: {
|
||||
weekday: String,
|
||||
weekend: String
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Theme and styling
|
||||
theme: {
|
||||
// Color palette
|
||||
colors: {
|
||||
primary: { type: String, default: '#1F423C' },
|
||||
primaryLight: { type: String, default: '#3A635F' },
|
||||
primaryDark: { type: String, default: '#0F2A26' },
|
||||
secondary: { type: String, default: '#9AD4BD' },
|
||||
secondaryLight: { type: String, default: '#B0E0D0' },
|
||||
secondaryDark: { type: String, default: '#7CBF9E' },
|
||||
tertiary: { type: String, default: '#A8A8A8' },
|
||||
background: { type: String, default: '#F8F6F3' },
|
||||
backgroundAlt: { type: String, default: '#E0E8E6' },
|
||||
text: { type: String, default: '#231F20' },
|
||||
textSecondary: { type: String, default: '#6D6E6E' }
|
||||
},
|
||||
|
||||
// Typography
|
||||
fonts: {
|
||||
heading: { type: String, default: 'Cormorant Garamond' },
|
||||
body: { type: String, default: 'Cairo' },
|
||||
headingWeight: { type: String, default: '600' },
|
||||
bodyWeight: { type: String, default: '400' }
|
||||
},
|
||||
|
||||
// Layout
|
||||
layout: {
|
||||
headerStyle: { type: String, default: 'transparent' },
|
||||
footerStyle: { type: String, default: 'dark' },
|
||||
borderRadius: { type: String, default: '4px' },
|
||||
spacing: { type: String, default: 'comfortable' }
|
||||
}
|
||||
},
|
||||
|
||||
// SEO settings
|
||||
seo: {
|
||||
defaultTitle: String,
|
||||
titleTemplate: String, // e.g., '%s | The Old Vine Hotel'
|
||||
defaultDescription: String,
|
||||
keywords: [String],
|
||||
ogImage: String,
|
||||
twitterHandle: String,
|
||||
googleAnalyticsId: String,
|
||||
googleTagManagerId: String,
|
||||
facebookPixelId: String
|
||||
},
|
||||
|
||||
// Booking settings
|
||||
booking: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
minNights: { type: Number, default: 1 },
|
||||
maxNights: { type: Number, default: 30 },
|
||||
advanceBookingDays: { type: Number, default: 365 },
|
||||
cancellationPolicy: String,
|
||||
depositRequired: { type: Boolean, default: false },
|
||||
depositPercentage: { type: Number, default: 30 },
|
||||
taxRate: { type: Number, default: 10 },
|
||||
currency: { type: String, default: 'USD' },
|
||||
currencySymbol: { type: String, default: '$' }
|
||||
},
|
||||
|
||||
// Email settings
|
||||
email: {
|
||||
fromName: String,
|
||||
fromEmail: String,
|
||||
replyToEmail: String,
|
||||
bookingConfirmationEnabled: { type: Boolean, default: true },
|
||||
bookingReminderEnabled: { type: Boolean, default: true },
|
||||
newsletterEnabled: { type: Boolean, default: true }
|
||||
},
|
||||
|
||||
// Feature flags
|
||||
features: {
|
||||
blog: { type: Boolean, default: true },
|
||||
gallery: { type: Boolean, default: true },
|
||||
testimonials: { type: Boolean, default: true },
|
||||
newsletter: { type: Boolean, default: true },
|
||||
liveChat: { type: Boolean, default: false },
|
||||
multiLanguage: { type: Boolean, default: true },
|
||||
darkMode: { type: Boolean, default: false }
|
||||
},
|
||||
|
||||
// Languages
|
||||
languages: {
|
||||
default: { type: String, default: 'en' },
|
||||
available: [{ type: String, default: ['en', 'ar', 'fr'] }],
|
||||
rtlLanguages: [{ type: String, default: ['ar'] }]
|
||||
},
|
||||
|
||||
// Maintenance mode
|
||||
maintenance: {
|
||||
enabled: { type: Boolean, default: false },
|
||||
message: String,
|
||||
allowedIPs: [String]
|
||||
},
|
||||
|
||||
// Version control
|
||||
version: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
|
||||
// Audit trail
|
||||
lastModifiedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin'
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Ensure only one settings document exists
|
||||
siteSettingsSchema.statics.getSiteSettings = async function() {
|
||||
let settings = await this.findOne();
|
||||
|
||||
if (!settings) {
|
||||
settings = await this.create({});
|
||||
}
|
||||
|
||||
return settings;
|
||||
};
|
||||
|
||||
// Pre-save middleware to update version
|
||||
siteSettingsSchema.pre('save', function(next) {
|
||||
if (this.isModified() && !this.isNew) {
|
||||
this.version += 1;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('SiteSettings', siteSettingsSchema);
|
||||
|
||||
6651
package-lock.json
generated
Normal file
6651
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "old-vine-hotel-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for The Old Vine Hotel website",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js",
|
||||
"test": "jest",
|
||||
"seed": "node scripts/seedDatabase.js",
|
||||
"seed:admin": "node scripts/seedAdmin.js",
|
||||
"seed:content": "node scripts/seedContent.js"
|
||||
},
|
||||
"keywords": [
|
||||
"hotel",
|
||||
"booking",
|
||||
"api",
|
||||
"nodejs",
|
||||
"express"
|
||||
],
|
||||
"author": "MiniMax Agent",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cloudinary": "^1.41.1",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"mongoose": "^8.0.3",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^14.7.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
||||
379
routes/admin.js
Normal file
379
routes/admin.js
Normal file
@@ -0,0 +1,379 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Admin = require('../models/Admin');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
// @route POST /api/admin/login
|
||||
// @desc Admin login
|
||||
// @access Public
|
||||
router.post('/login', [
|
||||
body('username').notEmpty().trim().withMessage('Username or email is required'),
|
||||
body('password').notEmpty().withMessage('Password is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Find admin by credentials
|
||||
const admin = await Admin.findByCredentials(username, password);
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: admin._id,
|
||||
email: admin.email,
|
||||
role: admin.role,
|
||||
isAdmin: true
|
||||
},
|
||||
process.env.JWT_SECRET || 'your-secret-key',
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
data: {
|
||||
token,
|
||||
admin: {
|
||||
id: admin._id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
firstName: admin.firstName,
|
||||
lastName: admin.lastName,
|
||||
fullName: admin.fullName,
|
||||
avatar: admin.avatar,
|
||||
role: admin.role,
|
||||
permissions: admin.permissions
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Admin login error:', error);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: error.message || 'Invalid credentials'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/admin/register
|
||||
// @desc Register new admin (super-admin only)
|
||||
// @access Private (Super Admin)
|
||||
router.post('/register', adminAuth, [
|
||||
body('username').notEmpty().trim().toLowerCase().withMessage('Username is required'),
|
||||
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
|
||||
body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
|
||||
body('firstName').notEmpty().trim().withMessage('First name is required'),
|
||||
body('lastName').notEmpty().trim().withMessage('Last name is required'),
|
||||
body('role').optional().isIn(['admin', 'editor', 'manager']).withMessage('Invalid role')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
// Check if requester is super admin
|
||||
const requester = await Admin.findById(req.admin.id);
|
||||
if (!requester || !requester.isSuperAdmin) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Only super admins can create new admin accounts'
|
||||
});
|
||||
}
|
||||
|
||||
const { username, email, password, firstName, lastName, role, permissions } = req.body;
|
||||
|
||||
// Check if admin already exists
|
||||
const existingAdmin = await Admin.findOne({
|
||||
$or: [{ username }, { email }]
|
||||
});
|
||||
|
||||
if (existingAdmin) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Admin with this username or email already exists'
|
||||
});
|
||||
}
|
||||
|
||||
// Create new admin
|
||||
const newAdmin = new Admin({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
role: role || 'admin',
|
||||
permissions: permissions || ['manage_content', 'manage_rooms', 'manage_bookings']
|
||||
});
|
||||
|
||||
await newAdmin.save();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Admin account created successfully',
|
||||
data: {
|
||||
admin: {
|
||||
id: newAdmin._id,
|
||||
username: newAdmin.username,
|
||||
email: newAdmin.email,
|
||||
fullName: newAdmin.fullName,
|
||||
role: newAdmin.role,
|
||||
permissions: newAdmin.permissions
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Admin registration error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error creating admin account'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/admin/me
|
||||
// @desc Get current admin profile
|
||||
// @access Private (Admin)
|
||||
router.get('/me', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const admin = await Admin.findById(req.admin.id);
|
||||
|
||||
if (!admin) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Admin not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { admin }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get admin profile error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching admin profile'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/admin/me
|
||||
// @desc Update current admin profile
|
||||
// @access Private (Admin)
|
||||
router.put('/me', adminAuth, [
|
||||
body('firstName').optional().notEmpty().trim(),
|
||||
body('lastName').optional().notEmpty().trim(),
|
||||
body('email').optional().isEmail().normalizeEmail(),
|
||||
body('avatar').optional().isURL()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { firstName, lastName, email, avatar } = req.body;
|
||||
const updateFields = {};
|
||||
|
||||
if (firstName) updateFields.firstName = firstName;
|
||||
if (lastName) updateFields.lastName = lastName;
|
||||
if (email) updateFields.email = email;
|
||||
if (avatar) updateFields.avatar = avatar;
|
||||
|
||||
const admin = await Admin.findByIdAndUpdate(
|
||||
req.admin.id,
|
||||
{ $set: updateFields },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Profile updated successfully',
|
||||
data: { admin }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update admin profile error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error updating profile'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/admin/change-password
|
||||
// @desc Change admin password
|
||||
// @access Private (Admin)
|
||||
router.put('/change-password', adminAuth, [
|
||||
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
||||
body('newPassword').isLength({ min: 8 }).withMessage('New password must be at least 8 characters')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const admin = await Admin.findById(req.admin.id);
|
||||
|
||||
if (!admin) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Admin not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isMatch = await admin.comparePassword(currentPassword);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Current password is incorrect'
|
||||
});
|
||||
}
|
||||
|
||||
// Update password
|
||||
admin.password = newPassword;
|
||||
await admin.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Password changed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error changing password'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/admin/list
|
||||
// @desc Get all admins (super-admin only)
|
||||
// @access Private (Super Admin)
|
||||
router.get('/list', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const requester = await Admin.findById(req.admin.id);
|
||||
if (!requester || !requester.isSuperAdmin) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Only super admins can view admin list'
|
||||
});
|
||||
}
|
||||
|
||||
const admins = await Admin.find().sort({ createdAt: -1 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { admins }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get admin list error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching admin list'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/admin/stats
|
||||
// @desc Get dashboard statistics
|
||||
// @access Private (Admin)
|
||||
router.get('/stats', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const Booking = require('../models/Booking');
|
||||
const Room = require('../models/Room');
|
||||
const Guest = require('../models/Guest');
|
||||
const BlogPost = require('../models/BlogPost');
|
||||
|
||||
const [
|
||||
totalBookings,
|
||||
activeBookings,
|
||||
totalRooms,
|
||||
availableRooms,
|
||||
totalGuests,
|
||||
totalBlogPosts
|
||||
] = await Promise.all([
|
||||
Booking.countDocuments(),
|
||||
Booking.countDocuments({ status: { $in: ['Confirmed', 'Checked In'] } }),
|
||||
Room.countDocuments(),
|
||||
Room.countDocuments({ status: 'Available', isActive: true }),
|
||||
Guest.countDocuments(),
|
||||
BlogPost.countDocuments({ status: 'published' })
|
||||
]);
|
||||
|
||||
// Get revenue for current month
|
||||
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
|
||||
const revenueData = await Booking.aggregate([
|
||||
{
|
||||
$match: {
|
||||
status: { $in: ['Confirmed', 'Checked In', 'Checked Out'] },
|
||||
createdAt: { $gte: startOfMonth }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalRevenue: { $sum: '$totalAmount' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const monthlyRevenue = revenueData.length > 0 ? revenueData[0].totalRevenue : 0;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
stats: {
|
||||
bookings: {
|
||||
total: totalBookings,
|
||||
active: activeBookings
|
||||
},
|
||||
rooms: {
|
||||
total: totalRooms,
|
||||
available: availableRooms
|
||||
},
|
||||
guests: totalGuests,
|
||||
blogPosts: totalBlogPosts,
|
||||
revenue: {
|
||||
monthly: monthlyRevenue
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get stats error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching statistics'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Health check
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({ success: true, service: 'admin', status: 'ok' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
509
routes/auth.js
Normal file
509
routes/auth.js
Normal file
@@ -0,0 +1,509 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Guest = require('../models/Guest');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const auth = require('../middleware/auth');
|
||||
const sendEmail = require('../utils/sendEmail');
|
||||
const logger = require('../utils/logger');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Generate JWT token
|
||||
const generateToken = (payload) => {
|
||||
return jwt.sign(payload, process.env.JWT_SECRET, {
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '7d'
|
||||
});
|
||||
};
|
||||
|
||||
// @route POST /api/auth/register
|
||||
// @desc Register a new guest
|
||||
// @access Public
|
||||
router.post('/register', [
|
||||
body('firstName').notEmpty().withMessage('First name is required').trim(),
|
||||
body('lastName').notEmpty().withMessage('Last name is required').trim(),
|
||||
body('email').isEmail().withMessage('Valid email is required').normalizeEmail(),
|
||||
body('phone').notEmpty().withMessage('Phone number is required').trim(),
|
||||
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
|
||||
body('confirmPassword').custom((value, { req }) => {
|
||||
if (value !== req.body.password) {
|
||||
throw new Error('Password confirmation does not match password');
|
||||
}
|
||||
return value;
|
||||
})
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { firstName, lastName, email, phone, password } = req.body;
|
||||
|
||||
// Check if guest already exists
|
||||
let guest = await Guest.findOne({ email });
|
||||
if (guest && guest.isRegistered) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Guest already registered with this email'
|
||||
});
|
||||
}
|
||||
|
||||
// Create or update guest
|
||||
if (guest) {
|
||||
// Update existing guest profile
|
||||
guest.firstName = firstName;
|
||||
guest.lastName = lastName;
|
||||
guest.phone = phone;
|
||||
guest.password = password;
|
||||
guest.isRegistered = true;
|
||||
guest.emailVerified = false;
|
||||
} else {
|
||||
// Create new guest
|
||||
guest = new Guest({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
password,
|
||||
isRegistered: true,
|
||||
emailVerified: false
|
||||
});
|
||||
}
|
||||
|
||||
await guest.save();
|
||||
|
||||
// Generate email verification token
|
||||
const verificationToken = crypto.randomBytes(32).toString('hex');
|
||||
guest.emailVerificationToken = verificationToken;
|
||||
await guest.save();
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Verify Your Email - The Old Vine Hotel',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="background: #8B4513; color: white; padding: 20px; text-align: center;">
|
||||
<h1>The Old Vine Hotel</h1>
|
||||
<h2>Email Verification</h2>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px;">
|
||||
<p>Dear ${firstName} ${lastName},</p>
|
||||
|
||||
<p>Thank you for registering with The Old Vine Hotel! Please verify your email address to complete your registration.</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${process.env.CLIENT_URL}/verify-email?token=${verificationToken}"
|
||||
style="background: #D4AF37; color: black; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">
|
||||
Verify Email Address
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666;">${process.env.CLIENT_URL}/verify-email?token=${verificationToken}</p>
|
||||
|
||||
<p>This verification link will expire in 24 hours.</p>
|
||||
|
||||
<p>If you didn't create an account with us, please ignore this email.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
The Old Vine Hotel Team</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f4f4f4; padding: 20px; text-align: center; font-size: 12px;">
|
||||
<p>© 2025 The Old Vine Hotel. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
} catch (emailError) {
|
||||
logger.error('Failed to send verification email:', emailError);
|
||||
// Don't fail registration if email fails
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
id: guest._id,
|
||||
email: guest.email,
|
||||
isAdmin: false
|
||||
});
|
||||
|
||||
logger.info('Guest registered successfully', {
|
||||
guestId: guest._id,
|
||||
email: guest.email
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Registration successful. Please check your email to verify your account.',
|
||||
data: {
|
||||
token,
|
||||
guest: {
|
||||
id: guest._id,
|
||||
firstName: guest.firstName,
|
||||
lastName: guest.lastName,
|
||||
email: guest.email,
|
||||
phone: guest.phone,
|
||||
emailVerified: guest.emailVerified,
|
||||
loyaltyProgram: guest.loyaltyProgram
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Registration error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error during registration'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/auth/login
|
||||
// @desc Login guest
|
||||
// @access Public
|
||||
router.post('/login', [
|
||||
body('email').isEmail().withMessage('Valid email is required').normalizeEmail(),
|
||||
body('password').notEmpty().withMessage('Password is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Find guest with password field
|
||||
const guest = await Guest.findOne({ email, isRegistered: true }).select('+password');
|
||||
|
||||
if (!guest) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid email or password'
|
||||
});
|
||||
}
|
||||
|
||||
if (!guest.isActive) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Account is deactivated. Please contact support.'
|
||||
});
|
||||
}
|
||||
|
||||
// Check password
|
||||
const isMatch = await guest.comparePassword(password);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid email or password'
|
||||
});
|
||||
}
|
||||
|
||||
// Update last login
|
||||
guest.lastLogin = new Date();
|
||||
guest.lastActivity = new Date();
|
||||
await guest.save();
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
id: guest._id,
|
||||
email: guest.email,
|
||||
isAdmin: false
|
||||
});
|
||||
|
||||
logger.info('Guest login successful', {
|
||||
guestId: guest._id,
|
||||
email: guest.email
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
data: {
|
||||
token,
|
||||
guest: {
|
||||
id: guest._id,
|
||||
firstName: guest.firstName,
|
||||
lastName: guest.lastName,
|
||||
email: guest.email,
|
||||
phone: guest.phone,
|
||||
emailVerified: guest.emailVerified,
|
||||
loyaltyProgram: guest.loyaltyProgram,
|
||||
preferences: guest.preferences,
|
||||
isVIP: guest.isVIP
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error during login'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/auth/me
|
||||
// @desc Get current guest
|
||||
// @access Private
|
||||
router.get('/me', auth, async (req, res) => {
|
||||
try {
|
||||
// Update last activity
|
||||
req.guest.lastActivity = new Date();
|
||||
await req.guest.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
guest: {
|
||||
id: req.guest._id,
|
||||
firstName: req.guest.firstName,
|
||||
lastName: req.guest.lastName,
|
||||
email: req.guest.email,
|
||||
phone: req.guest.phone,
|
||||
emailVerified: req.guest.emailVerified,
|
||||
loyaltyProgram: req.guest.loyaltyProgram,
|
||||
preferences: req.guest.preferences,
|
||||
isVIP: req.guest.isVIP,
|
||||
totalStays: req.guest.totalStays,
|
||||
totalSpent: req.guest.totalSpent
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get current guest error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/auth/forgot-password
|
||||
// @desc Send password reset email
|
||||
// @access Public
|
||||
router.post('/forgot-password', [
|
||||
body('email').isEmail().withMessage('Valid email is required').normalizeEmail()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { email } = req.body;
|
||||
|
||||
const guest = await Guest.findOne({ email, isRegistered: true });
|
||||
if (!guest) {
|
||||
// Don't reveal if email exists
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'If an account with that email exists, we have sent a password reset link.'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
const resetToken = guest.createPasswordResetToken();
|
||||
await guest.save();
|
||||
|
||||
// Send reset email
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Password Reset - The Old Vine Hotel',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="background: #8B4513; color: white; padding: 20px; text-align: center;">
|
||||
<h1>The Old Vine Hotel</h1>
|
||||
<h2>Password Reset</h2>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px;">
|
||||
<p>Dear ${guest.firstName} ${guest.lastName},</p>
|
||||
|
||||
<p>You have requested to reset your password. Click the button below to reset it:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${process.env.CLIENT_URL}/reset-password?token=${resetToken}"
|
||||
style="background: #D4AF37; color: black; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">
|
||||
Reset Password
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666;">${process.env.CLIENT_URL}/reset-password?token=${resetToken}</p>
|
||||
|
||||
<p>This reset link will expire in 10 minutes.</p>
|
||||
|
||||
<p>If you didn't request this password reset, please ignore this email.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
The Old Vine Hotel Team</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f4f4f4; padding: 20px; text-align: center; font-size: 12px;">
|
||||
<p>© 2025 The Old Vine Hotel. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
} catch (emailError) {
|
||||
logger.error('Failed to send password reset email:', emailError);
|
||||
guest.passwordResetToken = undefined;
|
||||
guest.passwordResetExpires = undefined;
|
||||
await guest.save();
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error sending password reset email'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'If an account with that email exists, we have sent a password reset link.'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Forgot password error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/auth/reset-password
|
||||
// @desc Reset password with token
|
||||
// @access Public
|
||||
router.post('/reset-password', [
|
||||
body('token').notEmpty().withMessage('Reset token is required'),
|
||||
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
|
||||
body('confirmPassword').custom((value, { req }) => {
|
||||
if (value !== req.body.password) {
|
||||
throw new Error('Password confirmation does not match password');
|
||||
}
|
||||
return value;
|
||||
})
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { token, password } = req.body;
|
||||
|
||||
// Hash the token
|
||||
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
// Find guest by token and check if token is still valid
|
||||
const guest = await Guest.findOne({
|
||||
passwordResetToken: hashedToken,
|
||||
passwordResetExpires: { $gt: Date.now() },
|
||||
isRegistered: true
|
||||
});
|
||||
|
||||
if (!guest) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Token is invalid or has expired'
|
||||
});
|
||||
}
|
||||
|
||||
// Set new password
|
||||
guest.password = password;
|
||||
guest.passwordResetToken = undefined;
|
||||
guest.passwordResetExpires = undefined;
|
||||
await guest.save();
|
||||
|
||||
logger.info('Password reset successful', {
|
||||
guestId: guest._id,
|
||||
email: guest.email
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Password reset successful. You can now log in with your new password.'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Reset password error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error during password reset'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/auth/verify-email
|
||||
// @desc Verify email with token
|
||||
// @access Public
|
||||
router.post('/verify-email', [
|
||||
body('token').notEmpty().withMessage('Verification token is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { token } = req.body;
|
||||
|
||||
const guest = await Guest.findOne({
|
||||
emailVerificationToken: token,
|
||||
isRegistered: true
|
||||
});
|
||||
|
||||
if (!guest) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid verification token'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify email
|
||||
guest.emailVerified = true;
|
||||
guest.emailVerificationToken = undefined;
|
||||
await guest.save();
|
||||
|
||||
logger.info('Email verification successful', {
|
||||
guestId: guest._id,
|
||||
email: guest.email
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Email verified successfully!'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Email verification error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error during email verification'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
286
routes/blog.js
Normal file
286
routes/blog.js
Normal file
@@ -0,0 +1,286 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const BlogPost = require('../models/BlogPost');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
// @route GET /api/blog
|
||||
// @desc Get published blog posts
|
||||
// @access Public
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
category,
|
||||
tag,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
featured = false
|
||||
} = req.query;
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const posts = await BlogPost.getPublished({
|
||||
category,
|
||||
tag,
|
||||
limit: parseInt(limit),
|
||||
skip,
|
||||
featured: featured === 'true'
|
||||
});
|
||||
|
||||
const total = await BlogPost.countDocuments({
|
||||
status: 'published',
|
||||
publishedAt: { $lte: new Date() },
|
||||
...(category && { category }),
|
||||
...(tag && { tags: tag }),
|
||||
...(featured === 'true' && { isFeatured: true })
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
posts,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get blog posts error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching blog posts'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/blog/:slug
|
||||
// @desc Get single blog post by slug
|
||||
// @access Public
|
||||
router.get('/:slug', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
const post = await BlogPost.findOne({
|
||||
slug,
|
||||
status: 'published',
|
||||
publishedAt: { $lte: new Date() }
|
||||
}).populate('author', 'firstName lastName avatar');
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Blog post not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Increment views
|
||||
await BlogPost.incrementViews(post._id);
|
||||
|
||||
// Get related posts
|
||||
const relatedPosts = await post.getRelatedPosts(3);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
post,
|
||||
relatedPosts
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get blog post error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching blog post'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/blog/admin/all
|
||||
// @desc Get all blog posts (admin)
|
||||
// @access Private (Admin)
|
||||
router.get('/admin/all', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { status, category, page = 1, limit = 20 } = req.query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const query = {};
|
||||
if (status) query.status = status;
|
||||
if (category) query.category = category;
|
||||
|
||||
const posts = await BlogPost.find(query)
|
||||
.populate('author', 'firstName lastName avatar')
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit));
|
||||
|
||||
const total = await BlogPost.countDocuments(query);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
posts,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get all blog posts error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching blog posts'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/blog
|
||||
// @desc Create new blog post
|
||||
// @access Private (Admin)
|
||||
router.post('/', adminAuth, [
|
||||
body('title').notEmpty().trim().withMessage('Title is required'),
|
||||
body('excerpt').notEmpty().trim().withMessage('Excerpt is required'),
|
||||
body('content').notEmpty().withMessage('Content is required'),
|
||||
body('category').notEmpty().withMessage('Category is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const postData = {
|
||||
...req.body,
|
||||
author: req.admin.id
|
||||
};
|
||||
|
||||
const post = new BlogPost(postData);
|
||||
await post.save();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Blog post created successfully',
|
||||
data: { post }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create blog post error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error creating blog post'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/blog/:id
|
||||
// @desc Update blog post
|
||||
// @access Private (Admin)
|
||||
router.put('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const post = await BlogPost.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: req.body },
|
||||
{ new: true, runValidators: true }
|
||||
).populate('author', 'firstName lastName avatar');
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Blog post not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Blog post updated successfully',
|
||||
data: { post }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update blog post error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error updating blog post'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/blog/:id
|
||||
// @desc Delete blog post
|
||||
// @access Private (Admin)
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const post = await BlogPost.findByIdAndDelete(id);
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Blog post not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Blog post deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete blog post error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error deleting blog post'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/blog/categories/list
|
||||
// @desc Get list of all categories
|
||||
// @access Public
|
||||
router.get('/categories/list', async (req, res) => {
|
||||
try {
|
||||
const categories = await BlogPost.distinct('category');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { categories }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get categories error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching categories'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/blog/tags/list
|
||||
// @desc Get list of all tags
|
||||
// @access Public
|
||||
router.get('/tags/list', async (req, res) => {
|
||||
try {
|
||||
const tags = await BlogPost.distinct('tags');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { tags }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get tags error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching tags'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
608
routes/bookings.js
Normal file
608
routes/bookings.js
Normal file
@@ -0,0 +1,608 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Booking = require('../models/Booking');
|
||||
const Room = require('../models/Room');
|
||||
const Guest = require('../models/Guest');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const auth = require('../middleware/auth');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
const sendEmail = require('../utils/sendEmail');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// @route POST /api/bookings
|
||||
// @desc Create a new booking
|
||||
// @access Public
|
||||
router.post('/', [
|
||||
body('guestInfo.firstName').notEmpty().withMessage('First name is required'),
|
||||
body('guestInfo.lastName').notEmpty().withMessage('Last name is required'),
|
||||
body('guestInfo.email').isEmail().withMessage('Valid email is required'),
|
||||
body('guestInfo.phone').notEmpty().withMessage('Phone number is required'),
|
||||
body('roomId').isMongoId().withMessage('Valid room ID is required'),
|
||||
body('checkInDate').isISO8601().withMessage('Valid check-in date is required'),
|
||||
body('checkOutDate').isISO8601().withMessage('Valid check-out date is required'),
|
||||
body('numberOfGuests.adults').isInt({ min: 1 }).withMessage('At least 1 adult required'),
|
||||
body('numberOfGuests.children').optional().isInt({ min: 0 }),
|
||||
body('paymentMethodId').notEmpty().withMessage('Payment method is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
guestInfo,
|
||||
roomId,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
numberOfGuests,
|
||||
specialRequests,
|
||||
paymentMethodId
|
||||
} = req.body;
|
||||
|
||||
const checkIn = new Date(checkInDate);
|
||||
const checkOut = new Date(checkOutDate);
|
||||
|
||||
// Validate dates
|
||||
if (checkIn >= checkOut) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Check-out date must be after check-in date'
|
||||
});
|
||||
}
|
||||
|
||||
if (checkIn < new Date()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Check-in date cannot be in the past'
|
||||
});
|
||||
}
|
||||
|
||||
// Check room availability
|
||||
const room = await Room.findById(roomId);
|
||||
if (!room || !room.isActive) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Room not found'
|
||||
});
|
||||
}
|
||||
|
||||
const totalGuests = numberOfGuests.adults + (numberOfGuests.children || 0);
|
||||
if (room.maxOccupancy < totalGuests) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Room can accommodate maximum ${room.maxOccupancy} guests`
|
||||
});
|
||||
}
|
||||
|
||||
const isAvailable = await room.isAvailable(checkIn, checkOut);
|
||||
if (!isAvailable) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Room is not available for the selected dates'
|
||||
});
|
||||
}
|
||||
|
||||
// Find or create guest
|
||||
let guest = await Guest.findOne({ email: guestInfo.email });
|
||||
if (!guest) {
|
||||
guest = new Guest({
|
||||
...guestInfo,
|
||||
isRegistered: false
|
||||
});
|
||||
await guest.save();
|
||||
} else {
|
||||
// Update guest information if provided
|
||||
Object.assign(guest, guestInfo);
|
||||
await guest.save();
|
||||
}
|
||||
|
||||
// Calculate pricing
|
||||
const numberOfNights = Math.ceil((checkOut - checkIn) / (1000 * 60 * 60 * 24));
|
||||
const roomRate = room.currentPrice;
|
||||
const subtotal = roomRate * numberOfNights;
|
||||
const taxes = subtotal * 0.12; // 12% tax
|
||||
const totalAmount = subtotal + taxes;
|
||||
|
||||
// Create Stripe payment intent
|
||||
let paymentIntent;
|
||||
try {
|
||||
paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: Math.round(totalAmount * 100), // Stripe uses cents
|
||||
currency: 'usd',
|
||||
payment_method: paymentMethodId,
|
||||
confirmation_method: 'manual',
|
||||
confirm: true,
|
||||
metadata: {
|
||||
roomId: roomId,
|
||||
guestEmail: guestInfo.email,
|
||||
checkInDate: checkInDate,
|
||||
checkOutDate: checkOutDate
|
||||
}
|
||||
});
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe payment error:', stripeError);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Payment processing failed',
|
||||
error: stripeError.message
|
||||
});
|
||||
}
|
||||
|
||||
// Create booking
|
||||
const booking = new Booking({
|
||||
guest: guest._id,
|
||||
room: roomId,
|
||||
checkInDate: checkIn,
|
||||
checkOutDate: checkOut,
|
||||
numberOfGuests,
|
||||
roomRate,
|
||||
numberOfNights,
|
||||
subtotal,
|
||||
taxes,
|
||||
totalAmount,
|
||||
specialRequests,
|
||||
paymentStatus: paymentIntent.status === 'succeeded' ? 'Paid' : 'Pending',
|
||||
paymentMethod: 'Credit Card',
|
||||
stripePaymentIntentId: paymentIntent.id,
|
||||
status: paymentIntent.status === 'succeeded' ? 'Confirmed' : 'Pending',
|
||||
bookingSource: 'Direct'
|
||||
});
|
||||
|
||||
await booking.save();
|
||||
await booking.populate(['guest', 'room']);
|
||||
|
||||
// Update guest statistics if payment successful
|
||||
if (paymentIntent.status === 'succeeded') {
|
||||
await guest.updateStayStats(totalAmount);
|
||||
await guest.addLoyaltyPoints(Math.floor(totalAmount / 10)); // 1 point per $10
|
||||
}
|
||||
|
||||
// Send confirmation email
|
||||
if (paymentIntent.status === 'succeeded') {
|
||||
try {
|
||||
await sendEmail({
|
||||
to: guest.email,
|
||||
subject: 'Booking Confirmation - The Old Vine Hotel',
|
||||
template: 'bookingConfirmation',
|
||||
context: {
|
||||
guest: guest,
|
||||
booking: booking,
|
||||
room: room
|
||||
}
|
||||
});
|
||||
|
||||
booking.emailConfirmationSent = true;
|
||||
await booking.save();
|
||||
} catch (emailError) {
|
||||
logger.error('Email sending error:', emailError);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: paymentIntent.status === 'succeeded'
|
||||
? 'Booking confirmed successfully'
|
||||
: 'Booking created, payment processing',
|
||||
data: {
|
||||
booking,
|
||||
paymentIntent: {
|
||||
id: paymentIntent.id,
|
||||
status: paymentIntent.status,
|
||||
client_secret: paymentIntent.client_secret
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating booking:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while creating booking'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/bookings/:bookingNumber
|
||||
// @desc Get booking by booking number
|
||||
// @access Public (with confirmation code) / Private
|
||||
router.get('/:bookingNumber', async (req, res) => {
|
||||
try {
|
||||
const { bookingNumber } = req.params;
|
||||
const { confirmationCode } = req.query;
|
||||
|
||||
let booking;
|
||||
|
||||
// If confirmation code provided, allow public access
|
||||
if (confirmationCode) {
|
||||
booking = await Booking.findOne({
|
||||
bookingNumber,
|
||||
confirmationCode
|
||||
}).populate(['guest', 'room']);
|
||||
} else {
|
||||
// Otherwise require authentication (implement auth middleware check here)
|
||||
booking = await Booking.findOne({ bookingNumber })
|
||||
.populate(['guest', 'room']);
|
||||
}
|
||||
|
||||
if (!booking) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Booking not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: booking
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching booking:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching booking'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/bookings/:bookingNumber/cancel
|
||||
// @desc Cancel a booking
|
||||
// @access Public (with confirmation code)
|
||||
router.put('/:bookingNumber/cancel', [
|
||||
body('confirmationCode').notEmpty().withMessage('Confirmation code is required'),
|
||||
body('reason').optional().isLength({ max: 500 }).withMessage('Reason too long')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { bookingNumber } = req.params;
|
||||
const { confirmationCode, reason } = req.body;
|
||||
|
||||
const booking = await Booking.findOne({
|
||||
bookingNumber,
|
||||
confirmationCode
|
||||
}).populate(['guest', 'room']);
|
||||
|
||||
if (!booking) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Booking not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (!booking.canBeCancelled()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Booking cannot be cancelled at this time'
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate cancellation fee
|
||||
const cancellationFee = booking.calculateCancellationFee();
|
||||
const refundAmount = booking.totalAmount - cancellationFee;
|
||||
|
||||
// Process refund with Stripe
|
||||
if (booking.stripePaymentIntentId && refundAmount > 0) {
|
||||
try {
|
||||
await stripe.refunds.create({
|
||||
payment_intent: booking.stripePaymentIntentId,
|
||||
amount: Math.round(refundAmount * 100), // Stripe uses cents
|
||||
metadata: {
|
||||
bookingNumber: bookingNumber,
|
||||
reason: reason || 'Guest cancellation'
|
||||
}
|
||||
});
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe refund error:', stripeError);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Refund processing failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update booking
|
||||
booking.status = 'Cancelled';
|
||||
booking.cancellationReason = reason;
|
||||
booking.cancellationDate = new Date();
|
||||
booking.cancellationFee = cancellationFee;
|
||||
booking.refundAmount = refundAmount;
|
||||
booking.paymentStatus = refundAmount > 0 ? 'Refunded' : 'Paid';
|
||||
|
||||
await booking.save();
|
||||
|
||||
// Send cancellation email
|
||||
try {
|
||||
await sendEmail({
|
||||
to: booking.guest.email,
|
||||
subject: 'Booking Cancellation - The Old Vine Hotel',
|
||||
template: 'bookingCancellation',
|
||||
context: {
|
||||
guest: booking.guest,
|
||||
booking: booking,
|
||||
room: booking.room,
|
||||
cancellationFee,
|
||||
refundAmount
|
||||
}
|
||||
});
|
||||
} catch (emailError) {
|
||||
logger.error('Cancellation email error:', emailError);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Booking cancelled successfully',
|
||||
data: {
|
||||
bookingNumber,
|
||||
cancellationFee,
|
||||
refundAmount,
|
||||
status: booking.status
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error cancelling booking:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while cancelling booking'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/bookings
|
||||
// @desc Get all bookings (Admin only)
|
||||
// @access Private/Admin
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
status,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
guestEmail,
|
||||
roomType,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc'
|
||||
} = req.query;
|
||||
|
||||
// Build filter
|
||||
let filter = {};
|
||||
|
||||
if (status) filter.status = status;
|
||||
if (guestEmail) {
|
||||
const guest = await Guest.findOne({ email: guestEmail });
|
||||
if (guest) filter.guest = guest._id;
|
||||
}
|
||||
|
||||
if (checkInDate || checkOutDate) {
|
||||
filter.checkInDate = {};
|
||||
if (checkInDate) filter.checkInDate.$gte = new Date(checkInDate);
|
||||
if (checkOutDate) filter.checkInDate.$lte = new Date(checkOutDate);
|
||||
}
|
||||
|
||||
// Build aggregation pipeline for room type filter
|
||||
let aggregationPipeline = [{ $match: filter }];
|
||||
|
||||
if (roomType) {
|
||||
aggregationPipeline.push(
|
||||
{
|
||||
$lookup: {
|
||||
from: 'rooms',
|
||||
localField: 'room',
|
||||
foreignField: '_id',
|
||||
as: 'roomData'
|
||||
}
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
'roomData.type': roomType
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Add pagination and sorting
|
||||
const sortOptions = {};
|
||||
sortOptions[sortBy] = sortOrder === 'desc' ? -1 : 1;
|
||||
|
||||
aggregationPipeline.push(
|
||||
{ $sort: sortOptions },
|
||||
{ $skip: (parseInt(page) - 1) * parseInt(limit) },
|
||||
{ $limit: parseInt(limit) },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'guests',
|
||||
localField: 'guest',
|
||||
foreignField: '_id',
|
||||
as: 'guest'
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'rooms',
|
||||
localField: 'room',
|
||||
foreignField: '_id',
|
||||
as: 'room'
|
||||
}
|
||||
},
|
||||
{
|
||||
$unwind: '$guest'
|
||||
},
|
||||
{
|
||||
$unwind: '$room'
|
||||
}
|
||||
);
|
||||
|
||||
const [bookings, totalCount] = await Promise.all([
|
||||
Booking.aggregate(aggregationPipeline),
|
||||
Booking.countDocuments(filter)
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / parseInt(limit));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
bookings,
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages,
|
||||
totalCount,
|
||||
hasNextPage: parseInt(page) < totalPages,
|
||||
hasPrevPage: parseInt(page) > 1
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching bookings:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching bookings'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/bookings/:id/checkin
|
||||
// @desc Check in a guest (Admin only)
|
||||
// @access Private/Admin
|
||||
router.put('/:id/checkin', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const booking = await Booking.findById(req.params.id)
|
||||
.populate(['guest', 'room']);
|
||||
|
||||
if (!booking) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Booking not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (booking.status !== 'Confirmed') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Booking must be confirmed to check in'
|
||||
});
|
||||
}
|
||||
|
||||
// Update booking status
|
||||
booking.status = 'Checked In';
|
||||
booking.actualCheckInTime = new Date();
|
||||
await booking.save();
|
||||
|
||||
// Update room status
|
||||
const room = await Room.findById(booking.room._id);
|
||||
room.status = 'Occupied';
|
||||
await room.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Guest checked in successfully',
|
||||
data: booking
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error checking in guest:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error during check-in'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/bookings/:id/checkout
|
||||
// @desc Check out a guest (Admin only)
|
||||
// @access Private/Admin
|
||||
router.put('/:id/checkout', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const booking = await Booking.findById(req.params.id)
|
||||
.populate(['guest', 'room']);
|
||||
|
||||
if (!booking) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Booking not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (booking.status !== 'Checked In') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Guest must be checked in to check out'
|
||||
});
|
||||
}
|
||||
|
||||
// Update booking status
|
||||
booking.status = 'Checked Out';
|
||||
booking.actualCheckOutTime = new Date();
|
||||
await booking.save();
|
||||
|
||||
// Update room status
|
||||
const room = await Room.findById(booking.room._id);
|
||||
room.status = 'Available';
|
||||
room.cleaningStatus = 'Dirty';
|
||||
room.lastCleaning = new Date();
|
||||
await room.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Guest checked out successfully',
|
||||
data: booking
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error checking out guest:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error during check-out'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/bookings/analytics/revenue
|
||||
// @desc Get revenue analytics (Admin only)
|
||||
// @access Private/Admin
|
||||
router.get('/analytics/revenue', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate, groupBy = 'day' } = req.query;
|
||||
|
||||
const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const end = endDate ? new Date(endDate) : new Date();
|
||||
|
||||
const revenueData = await Booking.generateRevenueReport(start, end);
|
||||
|
||||
// Calculate total metrics
|
||||
const totalRevenue = revenueData.reduce((sum, day) => sum + day.totalRevenue, 0);
|
||||
const totalBookings = revenueData.reduce((sum, day) => sum + day.bookingsCount, 0);
|
||||
const averageBookingValue = totalBookings > 0 ? totalRevenue / totalBookings : 0;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
revenueData,
|
||||
summary: {
|
||||
totalRevenue,
|
||||
totalBookings,
|
||||
averageBookingValue,
|
||||
dateRange: { start, end }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error generating revenue analytics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while generating analytics'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
280
routes/contact.js
Normal file
280
routes/contact.js
Normal file
@@ -0,0 +1,280 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const sendEmail = require('../utils/sendEmail');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// @route POST /api/contact
|
||||
// @desc Send contact form message
|
||||
// @access Public
|
||||
router.post('/', [
|
||||
body('name').notEmpty().withMessage('Name is required').trim(),
|
||||
body('email').isEmail().withMessage('Valid email is required').normalizeEmail(),
|
||||
body('phone').optional().isMobilePhone().withMessage('Valid phone number required'),
|
||||
body('subject').optional().trim(),
|
||||
body('message').notEmpty().withMessage('Message is required').isLength({ min: 10, max: 1000 }).withMessage('Message must be between 10 and 1000 characters')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { name, email, phone, subject, message } = req.body;
|
||||
|
||||
// Log the contact form submission
|
||||
logger.info('Contact form submission received', {
|
||||
name,
|
||||
email,
|
||||
subject: subject || 'General Inquiry',
|
||||
ip: req.ip
|
||||
});
|
||||
|
||||
// Send email to hotel management
|
||||
try {
|
||||
await sendEmail({
|
||||
to: process.env.HOTEL_EMAIL || 'info@oldvinehotel.com',
|
||||
subject: `Contact Form: ${subject || 'General Inquiry'}`,
|
||||
template: 'contactForm',
|
||||
context: {
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
message,
|
||||
timestamp: new Date().toLocaleString()
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Contact form email sent successfully', { name, email });
|
||||
} catch (emailError) {
|
||||
logger.error('Failed to send contact form email:', emailError);
|
||||
// Don't fail the request if email fails
|
||||
}
|
||||
|
||||
// Send auto-reply to customer
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Thank you for contacting The Old Vine Hotel',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="background: #8B4513; color: white; padding: 20px; text-align: center;">
|
||||
<h1>The Old Vine Hotel</h1>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px;">
|
||||
<p>Dear ${name},</p>
|
||||
|
||||
<p>Thank you for contacting The Old Vine Hotel. We have received your message and will respond within 24 hours.</p>
|
||||
|
||||
<div style="background: #f9f9f9; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<h3>Your Message:</h3>
|
||||
<p><strong>Subject:</strong> ${subject || 'General Inquiry'}</p>
|
||||
<p><strong>Message:</strong> ${message}</p>
|
||||
</div>
|
||||
|
||||
<p>In the meantime, feel free to:</p>
|
||||
<ul>
|
||||
<li>Browse our rooms and amenities on our website</li>
|
||||
<li>Call us directly at +1 (555) 123-4567</li>
|
||||
<li>Follow us on social media for updates and special offers</li>
|
||||
</ul>
|
||||
|
||||
<p>We look forward to serving you!</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
The Old Vine Hotel Team</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f4f4f4; padding: 20px; text-align: center; font-size: 12px;">
|
||||
<p>© 2025 The Old Vine Hotel. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
} catch (emailError) {
|
||||
logger.error('Failed to send contact form auto-reply:', emailError);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Thank you for your message. We will get back to you within 24 hours.'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Contact form processing error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'An error occurred while processing your message. Please try again.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/contact/newsletter
|
||||
// @desc Subscribe to newsletter
|
||||
// @access Public
|
||||
router.post('/newsletter', [
|
||||
body('email').isEmail().withMessage('Valid email is required').normalizeEmail(),
|
||||
body('name').optional().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { email, name } = req.body;
|
||||
|
||||
// Log newsletter subscription
|
||||
logger.info('Newsletter subscription', {
|
||||
email,
|
||||
name: name || 'Not provided',
|
||||
ip: req.ip
|
||||
});
|
||||
|
||||
// In a real application, you would save this to a newsletter database
|
||||
// For now, we'll just send a confirmation email
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Welcome to The Old Vine Hotel Newsletter',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="background: #8B4513; color: white; padding: 20px; text-align: center;">
|
||||
<h1>The Old Vine Hotel</h1>
|
||||
<h2>Newsletter Subscription Confirmed</h2>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px;">
|
||||
<p>Dear ${name || 'Valued Guest'},</p>
|
||||
|
||||
<p>Thank you for subscribing to The Old Vine Hotel newsletter!</p>
|
||||
|
||||
<p>You'll now receive:</p>
|
||||
<ul>
|
||||
<li>Exclusive special offers and promotions</li>
|
||||
<li>Updates on hotel amenities and services</li>
|
||||
<li>Local event recommendations</li>
|
||||
<li>Seasonal packages and deals</li>
|
||||
</ul>
|
||||
|
||||
<p>We're excited to keep you informed about everything happening at The Old Vine Hotel.</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="https://oldvinehotel.com" style="background: #D4AF37; color: black; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">Visit Our Website</a>
|
||||
</div>
|
||||
|
||||
<p>Best regards,<br>
|
||||
The Old Vine Hotel Team</p>
|
||||
|
||||
<p style="font-size: 12px; color: #666; margin-top: 30px;">
|
||||
You can unsubscribe from these emails at any time by clicking the unsubscribe link in any newsletter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f4f4f4; padding: 20px; text-align: center; font-size: 12px;">
|
||||
<p>© 2025 The Old Vine Hotel. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
} catch (emailError) {
|
||||
logger.error('Failed to send newsletter confirmation:', emailError);
|
||||
// Don't fail the request if email fails
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Thank you for subscribing! You will receive a confirmation email shortly.'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Newsletter subscription error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'An error occurred while processing your subscription. Please try again.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/contact/info
|
||||
// @desc Get hotel contact information
|
||||
// @access Public
|
||||
router.get('/info', (req, res) => {
|
||||
const contactInfo = {
|
||||
hotel: {
|
||||
name: process.env.HOTEL_NAME || 'The Old Vine Hotel',
|
||||
address: {
|
||||
street: 'Old Damascus City',
|
||||
city: 'Damascus',
|
||||
state: 'Damascus Governorate',
|
||||
zipCode: '',
|
||||
country: 'Syria',
|
||||
formatted: process.env.HOTEL_ADDRESS || 'Old Damascus City'
|
||||
},
|
||||
phone: process.env.HOTEL_PHONE || '+963 986 703 070',
|
||||
email: process.env.HOTEL_EMAIL || 'info@oldvinehotel.com',
|
||||
website: process.env.HOTEL_WEBSITE || 'https://oldvinehotel.com',
|
||||
whatsapp: process.env.WHATSAPP_PHONE_NUMBER || '+963 986 703 070'
|
||||
},
|
||||
departments: {
|
||||
reservations: {
|
||||
phone: '+963 986 703 070',
|
||||
email: 'reservations@oldvinehotel.com',
|
||||
hours: '24/7'
|
||||
},
|
||||
concierge: {
|
||||
phone: '+963 986 703 070',
|
||||
email: 'concierge@oldvinehotel.com',
|
||||
hours: '6:00 AM - 12:00 AM'
|
||||
},
|
||||
restaurant: {
|
||||
phone: '+963 986 703 070',
|
||||
email: 'restaurant@oldvinehotel.com',
|
||||
hours: '6:30 AM - 11:00 PM'
|
||||
},
|
||||
spa: {
|
||||
phone: '+963 986 703 070',
|
||||
email: 'spa@oldvinehotel.com',
|
||||
hours: '8:00 AM - 9:00 PM'
|
||||
},
|
||||
events: {
|
||||
phone: '+963 986 703 070',
|
||||
email: 'events@oldvinehotel.com',
|
||||
hours: '9:00 AM - 6:00 PM'
|
||||
}
|
||||
},
|
||||
socialMedia: {
|
||||
facebook: 'https://facebook.com/oldvinehotel',
|
||||
instagram: 'https://instagram.com/oldvinehotel',
|
||||
twitter: 'https://twitter.com/oldvinehotel',
|
||||
linkedin: 'https://linkedin.com/company/oldvinehotel'
|
||||
},
|
||||
checkInOut: {
|
||||
checkIn: '3:00 PM',
|
||||
checkOut: '11:00 AM',
|
||||
earlyCheckIn: 'Available upon request (additional fee may apply)',
|
||||
lateCheckOut: 'Available upon request (additional fee may apply)'
|
||||
},
|
||||
policies: {
|
||||
cancellation: '24 hours before arrival',
|
||||
petPolicy: 'Pets allowed with prior arrangement',
|
||||
smokingPolicy: 'Non-smoking hotel',
|
||||
childrenPolicy: 'Children of all ages welcome'
|
||||
}
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: contactInfo
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
192
routes/content.js
Normal file
192
routes/content.js
Normal file
@@ -0,0 +1,192 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Content = require('../models/Content');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
// @route GET /api/content/:page
|
||||
// @desc Get content for a specific page
|
||||
// @access Public
|
||||
router.get('/:page', async (req, res) => {
|
||||
try {
|
||||
const { page } = req.params;
|
||||
|
||||
let content = await Content.findOne({ page, isPublished: true });
|
||||
|
||||
// If content doesn't exist, create default content
|
||||
if (!content) {
|
||||
content = await Content.create({
|
||||
page,
|
||||
hero: {
|
||||
title: `Welcome to ${page.charAt(0).toUpperCase() + page.slice(1)}`,
|
||||
subtitle: 'Discover luxury and comfort',
|
||||
description: 'Experience the finest hospitality'
|
||||
},
|
||||
sections: [],
|
||||
seo: {
|
||||
title: `${page.charAt(0).toUpperCase() + page.slice(1)} - The Old Vine Hotel`,
|
||||
description: `Explore our ${page} page`
|
||||
},
|
||||
isPublished: true
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { content }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get content error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching content'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/content/:page
|
||||
// @desc Update content for a specific page
|
||||
// @access Private (Admin)
|
||||
router.put('/:page', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { page } = req.params;
|
||||
const { hero, sections, seo, isPublished } = req.body;
|
||||
|
||||
let content = await Content.findOne({ page });
|
||||
|
||||
if (!content) {
|
||||
// Create new content
|
||||
content = new Content({
|
||||
page,
|
||||
hero,
|
||||
sections,
|
||||
seo,
|
||||
isPublished,
|
||||
lastModifiedBy: req.admin.id
|
||||
});
|
||||
} else {
|
||||
// Update existing content
|
||||
if (hero) content.hero = hero;
|
||||
if (sections) content.sections = sections;
|
||||
if (seo) content.seo = seo;
|
||||
if (typeof isPublished !== 'undefined') content.isPublished = isPublished;
|
||||
content.lastModifiedBy = req.admin.id;
|
||||
}
|
||||
|
||||
await content.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Content updated successfully',
|
||||
data: { content }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update content error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error updating content'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/content
|
||||
// @desc Get all content pages (admin)
|
||||
// @access Private (Admin)
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const contents = await Content.find()
|
||||
.populate('lastModifiedBy', 'firstName lastName')
|
||||
.sort({ page: 1 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { contents }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get all content error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching content'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/content/:page/section
|
||||
// @desc Add a section to page content
|
||||
// @access Private (Admin)
|
||||
router.post('/:page/section', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { page } = req.params;
|
||||
const section = req.body;
|
||||
|
||||
const content = await Content.findOne({ page });
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Content page not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Set section order if not provided
|
||||
if (!section.order) {
|
||||
section.order = content.sections.length;
|
||||
}
|
||||
|
||||
content.sections.push(section);
|
||||
content.lastModifiedBy = req.admin.id;
|
||||
await content.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Section added successfully',
|
||||
data: { content }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Add section error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error adding section'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/content/:page/section/:sectionId
|
||||
// @desc Remove a section from page content
|
||||
// @access Private (Admin)
|
||||
router.delete('/:page/section/:sectionId', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { page, sectionId } = req.params;
|
||||
|
||||
const content = await Content.findOne({ page });
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Content page not found'
|
||||
});
|
||||
}
|
||||
|
||||
content.sections = content.sections.filter(
|
||||
section => section.sectionId !== sectionId
|
||||
);
|
||||
|
||||
content.lastModifiedBy = req.admin.id;
|
||||
await content.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Section removed successfully',
|
||||
data: { content }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Remove section error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error removing section'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
11
routes/gallery.js
Normal file
11
routes/gallery.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Minimal gallery router stub to satisfy app.use
|
||||
router.get('/', (req, res) => {
|
||||
res.json({ success: true, images: [] });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
181
routes/galleryCategories.js
Normal file
181
routes/galleryCategories.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const GalleryCategory = require('../models/GalleryCategory');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
|
||||
// @route GET /api/gallery-categories
|
||||
// @desc Get all active gallery categories
|
||||
// @access Public
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const categories = await GalleryCategory.find({ isActive: true })
|
||||
.sort({ displayOrder: 1, name: 1 })
|
||||
.lean();
|
||||
|
||||
// Add virtual properties
|
||||
const categoriesWithStats = categories.map((category) => ({
|
||||
...category,
|
||||
primaryImage: category.images.find(img => img.isPrimary)?.url ||
|
||||
(category.images.length > 0 ? category.images[0].url : null),
|
||||
imageCount: category.images.length
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { categories: categoriesWithStats }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery categories:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching gallery categories'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/gallery-categories/:slug
|
||||
// @desc Get single gallery category with all images
|
||||
// @access Public
|
||||
router.get('/:slug', async (req, res) => {
|
||||
try {
|
||||
const category = await GalleryCategory.findOne({
|
||||
slug: req.params.slug,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Gallery category not found'
|
||||
});
|
||||
}
|
||||
|
||||
const categoryData = category.toObject();
|
||||
categoryData.primaryImage = category.primaryImage;
|
||||
categoryData.imageCount = category.images.length;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { category: categoryData }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery category:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching gallery category'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ADMIN ROUTES ====================
|
||||
|
||||
// @route GET /api/gallery-categories/admin/all
|
||||
// @desc Get all gallery categories (including inactive) - Admin only
|
||||
// @access Private/Admin
|
||||
router.get('/admin/all', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const categories = await GalleryCategory.find()
|
||||
.sort({ displayOrder: 1, name: 1 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { categories }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching all gallery categories:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching gallery categories'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/gallery-categories
|
||||
// @desc Create a new gallery category - Admin only
|
||||
// @access Private/Admin
|
||||
router.post('/', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const category = new GalleryCategory(req.body);
|
||||
await category.save();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Gallery category created successfully',
|
||||
data: { category }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating gallery category:', error);
|
||||
if (error.code === 11000) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Category with this name or slug already exists'
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while creating gallery category'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/gallery-categories/:id
|
||||
// @desc Update a gallery category - Admin only
|
||||
// @access Private/Admin
|
||||
router.put('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const category = await GalleryCategory.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
req.body,
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Gallery category not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Gallery category updated successfully',
|
||||
data: { category }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating gallery category:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while updating gallery category'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/gallery-categories/:id
|
||||
// @desc Delete a gallery category - Admin only
|
||||
// @access Private/Admin
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const category = await GalleryCategory.findByIdAndDelete(req.params.id);
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Gallery category not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Gallery category deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting gallery category:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while deleting gallery category'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
11
routes/guests.js
Normal file
11
routes/guests.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Minimal guests router stub to satisfy app.use
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({ success: true, service: 'guests', status: 'ok' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
347
routes/integrations.js
Normal file
347
routes/integrations.js
Normal file
@@ -0,0 +1,347 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const OperaPMSService = require('../services/OperaPMSService');
|
||||
const BookingComService = require('../services/BookingComService');
|
||||
const TripComService = require('../services/TripComService');
|
||||
const ExpediaService = require('../services/ExpediaService');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Initialize services
|
||||
const operaPMS = new OperaPMSService();
|
||||
const bookingCom = new BookingComService();
|
||||
const tripCom = new TripComService();
|
||||
const expedia = new ExpediaService();
|
||||
|
||||
// @route GET /api/integrations/health
|
||||
// @desc Check health of all integrations
|
||||
// @access Private/Admin
|
||||
router.get('/health', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const healthChecks = await Promise.allSettled([
|
||||
operaPMS.healthCheck(),
|
||||
bookingCom.healthCheck(),
|
||||
tripCom.healthCheck(),
|
||||
expedia.healthCheck()
|
||||
]);
|
||||
|
||||
const results = {
|
||||
operaPMS: healthChecks[0].status === 'fulfilled' ? healthChecks[0].value : { status: 'error', error: healthChecks[0].reason.message },
|
||||
bookingCom: healthChecks[1].status === 'fulfilled' ? healthChecks[1].value : { status: 'error', error: healthChecks[1].reason.message },
|
||||
tripCom: healthChecks[2].status === 'fulfilled' ? healthChecks[2].value : { status: 'error', error: healthChecks[2].reason.message },
|
||||
expedia: healthChecks[3].status === 'fulfilled' ? healthChecks[3].value : { status: 'error', error: healthChecks[3].reason.message }
|
||||
};
|
||||
|
||||
const overallStatus = Object.values(results).every(result => result.status === 'connected') ? 'healthy' : 'partial';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
overallStatus,
|
||||
services: results,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Integration health check failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to check integration health'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/integrations/opera/sync-rooms
|
||||
// @desc Sync room inventory with Opera PMS
|
||||
// @access Private/Admin
|
||||
router.post('/opera/sync-rooms', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const result = await operaPMS.syncRoomInventory();
|
||||
|
||||
logger.integrationLog('Room inventory sync completed', {
|
||||
admin: req.admin.email,
|
||||
result
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Room inventory synchronized successfully',
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Room inventory sync failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to sync room inventory'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/integrations/opera/create-reservation
|
||||
// @desc Create reservation in Opera PMS
|
||||
// @access Private (called internally)
|
||||
router.post('/opera/create-reservation', async (req, res) => {
|
||||
try {
|
||||
const { booking } = req.body;
|
||||
const result = await operaPMS.createReservation(booking);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Opera PMS reservation creation failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create reservation in Opera PMS'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/integrations/sync-rates
|
||||
// @desc Sync rates across all platforms
|
||||
// @access Private/Admin
|
||||
router.post('/sync-rates', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { roomType, rates, dates } = req.body;
|
||||
|
||||
// Sync rates to all platforms
|
||||
const syncPromises = [
|
||||
bookingCom.updateRates(roomType, rates, dates),
|
||||
tripCom.updateRates(roomType, rates, dates),
|
||||
expedia.updateRates(roomType, rates, dates)
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(syncPromises);
|
||||
|
||||
const syncResults = {
|
||||
bookingCom: results[0].status === 'fulfilled' ? results[0].value : { error: results[0].reason.message },
|
||||
tripCom: results[1].status === 'fulfilled' ? results[1].value : { error: results[1].reason.message },
|
||||
expedia: results[2].status === 'fulfilled' ? results[2].value : { error: results[2].reason.message }
|
||||
};
|
||||
|
||||
logger.integrationLog('Rate sync completed', {
|
||||
admin: req.admin.email,
|
||||
roomType,
|
||||
dates,
|
||||
results: syncResults
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Rates synchronized across platforms',
|
||||
data: syncResults
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Rate sync failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to sync rates'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/integrations/sync-availability
|
||||
// @desc Sync availability across all platforms
|
||||
// @access Private/Admin
|
||||
router.post('/sync-availability', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { roomType, availability, dates } = req.body;
|
||||
|
||||
// Sync availability to all platforms
|
||||
const syncPromises = [
|
||||
bookingCom.updateAvailability(roomType, availability, dates),
|
||||
tripCom.updateAvailability(roomType, availability, dates),
|
||||
expedia.updateAvailability(roomType, availability, dates)
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(syncPromises);
|
||||
|
||||
const syncResults = {
|
||||
bookingCom: results[0].status === 'fulfilled' ? results[0].value : { error: results[0].reason.message },
|
||||
tripCom: results[1].status === 'fulfilled' ? results[1].value : { error: results[1].reason.message },
|
||||
expedia: results[2].status === 'fulfilled' ? results[2].value : { error: results[2].reason.message }
|
||||
};
|
||||
|
||||
logger.integrationLog('Availability sync completed', {
|
||||
admin: req.admin.email,
|
||||
roomType,
|
||||
dates,
|
||||
results: syncResults
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Availability synchronized across platforms',
|
||||
data: syncResults
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Availability sync failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to sync availability'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/integrations/bookings/external
|
||||
// @desc Get bookings from external platforms
|
||||
// @access Private/Admin
|
||||
router.get('/bookings/external', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
const start = new Date(startDate || Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const end = new Date(endDate || Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch bookings from all platforms
|
||||
const bookingPromises = [
|
||||
bookingCom.getBookings(start, end),
|
||||
tripCom.getBookings(start, end),
|
||||
expedia.getBookings(start, end)
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(bookingPromises);
|
||||
|
||||
const externalBookings = {
|
||||
bookingCom: results[0].status === 'fulfilled' ? results[0].value : [],
|
||||
tripCom: results[1].status === 'fulfilled' ? results[1].value : [],
|
||||
expedia: results[2].status === 'fulfilled' ? results[2].value : []
|
||||
};
|
||||
|
||||
// Combine all bookings
|
||||
const allBookings = [
|
||||
...externalBookings.bookingCom.map(b => ({ ...b, source: 'Booking.com' })),
|
||||
...externalBookings.tripCom.map(b => ({ ...b, source: 'Trip.com' })),
|
||||
...externalBookings.expedia.map(b => ({ ...b, source: 'Expedia' }))
|
||||
];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
bookings: allBookings,
|
||||
summary: {
|
||||
total: allBookings.length,
|
||||
byPlatform: {
|
||||
bookingCom: externalBookings.bookingCom.length,
|
||||
tripCom: externalBookings.tripCom.length,
|
||||
expedia: externalBookings.expedia.length
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('External bookings fetch failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch external bookings'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/integrations/webhook/booking-com
|
||||
// @desc Handle Booking.com webhooks
|
||||
// @access Public (webhook)
|
||||
router.post('/webhook/booking-com', async (req, res) => {
|
||||
try {
|
||||
const webhookData = req.body;
|
||||
|
||||
logger.integrationLog('Booking.com webhook received', {
|
||||
type: webhookData.event_type,
|
||||
bookingId: webhookData.booking_id
|
||||
});
|
||||
|
||||
// Process webhook based on event type
|
||||
switch (webhookData.event_type) {
|
||||
case 'booking_created':
|
||||
await bookingCom.processNewBooking(webhookData);
|
||||
break;
|
||||
case 'booking_modified':
|
||||
await bookingCom.processBookingModification(webhookData);
|
||||
break;
|
||||
case 'booking_cancelled':
|
||||
await bookingCom.processBookingCancellation(webhookData);
|
||||
break;
|
||||
default:
|
||||
logger.integrationLog('Unknown Booking.com webhook event', {
|
||||
type: webhookData.event_type
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Webhook processed successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Booking.com webhook processing failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Webhook processing failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/integrations/webhook/expedia
|
||||
// @desc Handle Expedia webhooks
|
||||
// @access Public (webhook)
|
||||
router.post('/webhook/expedia', async (req, res) => {
|
||||
try {
|
||||
const webhookData = req.body;
|
||||
|
||||
logger.integrationLog('Expedia webhook received', {
|
||||
type: webhookData.event_type,
|
||||
bookingId: webhookData.booking_id
|
||||
});
|
||||
|
||||
// Process Expedia webhook
|
||||
await expedia.processWebhook(webhookData);
|
||||
|
||||
res.json({ success: true, message: 'Webhook processed successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Expedia webhook processing failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Webhook processing failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/integrations/analytics/platform-performance
|
||||
// @desc Get performance analytics for all platforms
|
||||
// @access Private/Admin
|
||||
router.get('/analytics/platform-performance', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
const start = new Date(startDate || Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const end = new Date(endDate || Date.now());
|
||||
|
||||
// Get performance data from all platforms
|
||||
const performancePromises = [
|
||||
bookingCom.getPerformanceData(start, end),
|
||||
tripCom.getPerformanceData(start, end),
|
||||
expedia.getPerformanceData(start, end)
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(performancePromises);
|
||||
|
||||
const performanceData = {
|
||||
bookingCom: results[0].status === 'fulfilled' ? results[0].value : null,
|
||||
tripCom: results[1].status === 'fulfilled' ? results[1].value : null,
|
||||
expedia: results[2].status === 'fulfilled' ? results[2].value : null
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
dateRange: { start, end },
|
||||
platforms: performanceData
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Platform performance analytics failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch platform performance data'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
350
routes/media.js
Normal file
350
routes/media.js
Normal file
@@ -0,0 +1,350 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const Media = require('../models/Media');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
|
||||
// Ensure upload directories exist
|
||||
const ensureUploadDir = async (dir) => {
|
||||
try {
|
||||
await fs.access(dir);
|
||||
} catch {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Configure multer storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const folder = req.body.folder || 'general';
|
||||
const uploadPath = path.join(__dirname, '../../client/public/images', folder);
|
||||
await ensureUploadDir(uploadPath);
|
||||
cb(null, uploadPath);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const ext = path.extname(file.originalname);
|
||||
const basename = path.basename(file.originalname, ext)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
cb(null, basename + '-' + uniqueSuffix + ext);
|
||||
}
|
||||
});
|
||||
|
||||
// File filter
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedMimeTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'video/mp4',
|
||||
'video/quicktime',
|
||||
'application/pdf'
|
||||
];
|
||||
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only images, videos, and PDFs are allowed.'), false);
|
||||
}
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB limit
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/media/upload
|
||||
// @desc Upload media file
|
||||
// @access Private (Admin)
|
||||
router.post('/upload', adminAuth, upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'No file uploaded'
|
||||
});
|
||||
}
|
||||
|
||||
const { folder = 'general', alt, caption, description, tags } = req.body;
|
||||
|
||||
// Determine media type
|
||||
let mediaType = 'other';
|
||||
if (req.file.mimetype.startsWith('image/')) {
|
||||
mediaType = 'image';
|
||||
} else if (req.file.mimetype.startsWith('video/')) {
|
||||
mediaType = 'video';
|
||||
} else if (req.file.mimetype === 'application/pdf') {
|
||||
mediaType = 'document';
|
||||
}
|
||||
|
||||
// Create media record
|
||||
const media = new Media({
|
||||
filename: req.file.filename,
|
||||
originalName: req.file.originalname,
|
||||
url: `/images/${folder}/${req.file.filename}`,
|
||||
mimeType: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
type: mediaType,
|
||||
folder,
|
||||
alt,
|
||||
caption,
|
||||
description,
|
||||
tags: tags ? tags.split(',').map(t => t.trim()) : [],
|
||||
uploadedBy: req.admin.id
|
||||
});
|
||||
|
||||
await media.save();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'File uploaded successfully',
|
||||
data: { media }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || 'Error uploading file'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/media/upload-multiple
|
||||
// @desc Upload multiple media files
|
||||
// @access Private (Admin)
|
||||
router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req, res) => {
|
||||
try {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'No files uploaded'
|
||||
});
|
||||
}
|
||||
|
||||
const { folder = 'general' } = req.body;
|
||||
const mediaRecords = [];
|
||||
|
||||
for (const file of req.files) {
|
||||
let mediaType = 'other';
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
mediaType = 'image';
|
||||
} else if (file.mimetype.startsWith('video/')) {
|
||||
mediaType = 'video';
|
||||
} else if (file.mimetype === 'application/pdf') {
|
||||
mediaType = 'document';
|
||||
}
|
||||
|
||||
const media = new Media({
|
||||
filename: file.filename,
|
||||
originalName: file.originalname,
|
||||
url: `/images/${folder}/${file.filename}`,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
type: mediaType,
|
||||
folder,
|
||||
uploadedBy: req.admin.id
|
||||
});
|
||||
|
||||
await media.save();
|
||||
mediaRecords.push(media);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `${mediaRecords.length} files uploaded successfully`,
|
||||
data: { media: mediaRecords }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Multiple upload error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || 'Error uploading files'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/media
|
||||
// @desc Get all media
|
||||
// @access Private (Admin)
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { folder, type, page = 1, limit = 50 } = req.query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const query = {};
|
||||
if (folder) query.folder = folder;
|
||||
if (type) query.type = type;
|
||||
|
||||
const media = await Media.find(query)
|
||||
.populate('uploadedBy', 'firstName lastName')
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit));
|
||||
|
||||
const total = await Media.countDocuments(query);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
media,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get media error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching media'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/media/search
|
||||
// @desc Search media
|
||||
// @access Private (Admin)
|
||||
router.get('/search', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { q, folder, type, limit = 50 } = req.query;
|
||||
|
||||
if (!q) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Search query is required'
|
||||
});
|
||||
}
|
||||
|
||||
const media = await Media.search(q, { folder, type, limit: parseInt(limit) });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { media }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search media error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error searching media'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/media/:id
|
||||
// @desc Update media metadata
|
||||
// @access Private (Admin)
|
||||
router.put('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { alt, caption, description, tags, folder } = req.body;
|
||||
|
||||
const updateFields = {};
|
||||
if (alt) updateFields.alt = alt;
|
||||
if (caption) updateFields.caption = caption;
|
||||
if (description) updateFields.description = description;
|
||||
if (tags) updateFields.tags = tags;
|
||||
if (folder) updateFields.folder = folder;
|
||||
|
||||
const media = await Media.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: updateFields },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!media) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Media not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Media updated successfully',
|
||||
data: { media }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update media error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error updating media'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/media/:id
|
||||
// @desc Delete media file
|
||||
// @access Private (Admin)
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const media = await Media.findById(id);
|
||||
|
||||
if (!media) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Media not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Delete file from filesystem
|
||||
const filePath = path.join(__dirname, '../../client/public', media.url);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (err) {
|
||||
console.error('Error deleting file:', err);
|
||||
}
|
||||
|
||||
// Delete media record
|
||||
await Media.findByIdAndDelete(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Media deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete media error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error deleting media'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/media/folders/list
|
||||
// @desc Get list of all folders
|
||||
// @access Private (Admin)
|
||||
router.get('/folders/list', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const folders = await Media.distinct('folder');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { folders }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get folders error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching folders'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
11
routes/payments.js
Normal file
11
routes/payments.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Minimal payments router stub to satisfy app.use
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({ success: true, service: 'payments', status: 'ok' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
226
routes/roomCategories.js
Normal file
226
routes/roomCategories.js
Normal file
@@ -0,0 +1,226 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const RoomCategory = require('../models/RoomCategory');
|
||||
const Room = require('../models/Room');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
|
||||
// @route GET /api/room-categories
|
||||
// @desc Get all active room categories
|
||||
// @access Public
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const categories = await RoomCategory.find({ isActive: true })
|
||||
.sort({ displayOrder: 1, name: 1 })
|
||||
.lean();
|
||||
|
||||
// Calculate room count and price range for each category
|
||||
const categoriesWithStats = await Promise.all(
|
||||
categories.map(async (category) => {
|
||||
const rooms = await Room.find({
|
||||
category: category._id,
|
||||
isActive: true
|
||||
}).lean();
|
||||
|
||||
const prices = rooms.map(r => r.basePrice).filter(p => p > 0);
|
||||
|
||||
return {
|
||||
...category,
|
||||
roomCount: rooms.length,
|
||||
priceRange: {
|
||||
min: prices.length > 0 ? Math.min(...prices) : 0,
|
||||
max: prices.length > 0 ? Math.max(...prices) : 0
|
||||
},
|
||||
primaryImage: category.images.find(img => img.isPrimary)?.url ||
|
||||
(category.images.length > 0 ? category.images[0].url : null),
|
||||
imageCount: category.images.length
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { categories: categoriesWithStats }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching room categories:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching room categories'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/room-categories/:slug
|
||||
// @desc Get single room category with all images and rooms
|
||||
// @access Public
|
||||
router.get('/:slug', async (req, res) => {
|
||||
try {
|
||||
const category = await RoomCategory.findOne({
|
||||
slug: req.params.slug,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Room category not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Get all rooms in this category
|
||||
const rooms = await Room.find({
|
||||
category: category._id,
|
||||
isActive: true
|
||||
})
|
||||
.sort({ basePrice: 1 })
|
||||
.lean();
|
||||
|
||||
// Calculate price range
|
||||
const prices = rooms.map(r => r.basePrice).filter(p => p > 0);
|
||||
const priceRange = {
|
||||
min: prices.length > 0 ? Math.min(...prices) : 0,
|
||||
max: prices.length > 0 ? Math.max(...prices) : 0
|
||||
};
|
||||
|
||||
const categoryData = category.toObject();
|
||||
categoryData.rooms = rooms;
|
||||
categoryData.roomCount = rooms.length;
|
||||
categoryData.priceRange = priceRange;
|
||||
categoryData.primaryImage = category.primaryImage;
|
||||
categoryData.imageCount = category.images.length;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { category: categoryData }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching room category:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching room category'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ADMIN ROUTES ====================
|
||||
|
||||
// @route GET /api/room-categories/admin/all
|
||||
// @desc Get all room categories (including inactive) - Admin only
|
||||
// @access Private/Admin
|
||||
router.get('/admin/all', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const categories = await RoomCategory.find()
|
||||
.sort({ displayOrder: 1, name: 1 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { categories }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching all room categories:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching room categories'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/room-categories
|
||||
// @desc Create a new room category - Admin only
|
||||
// @access Private/Admin
|
||||
router.post('/', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const category = new RoomCategory(req.body);
|
||||
await category.save();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Room category created successfully',
|
||||
data: { category }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating room category:', error);
|
||||
if (error.code === 11000) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Category with this name or slug already exists'
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while creating room category'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/room-categories/:id
|
||||
// @desc Update a room category - Admin only
|
||||
// @access Private/Admin
|
||||
router.put('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const category = await RoomCategory.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
req.body,
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Room category not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Room category updated successfully',
|
||||
data: { category }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating room category:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while updating room category'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/room-categories/:id
|
||||
// @desc Delete a room category - Admin only
|
||||
// @access Private/Admin
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
// Check if any rooms are using this category
|
||||
const roomsCount = await Room.countDocuments({ category: req.params.id });
|
||||
|
||||
if (roomsCount > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Cannot delete category. ${roomsCount} room(s) are using this category. Please reassign rooms first.`
|
||||
});
|
||||
}
|
||||
|
||||
const category = await RoomCategory.findByIdAndDelete(req.params.id);
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Room category not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Room category deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting room category:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while deleting room category'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
521
routes/rooms.js
Normal file
521
routes/rooms.js
Normal file
@@ -0,0 +1,521 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Room = require('../models/Room');
|
||||
const RoomCategory = require('../models/RoomCategory');
|
||||
const { body, validationResult, query } = require('express-validator');
|
||||
const auth = require('../middleware/auth');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
|
||||
// @route GET /api/rooms
|
||||
// @desc Get all rooms with filtering and pagination
|
||||
// @access Public
|
||||
router.get('/', [
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('type').optional().isIn(['Standard', 'Deluxe', 'Suite', 'Executive Suite', 'Presidential Suite']),
|
||||
query('maxPrice').optional().isFloat({ min: 0 }),
|
||||
query('minPrice').optional().isFloat({ min: 0 }),
|
||||
query('guests').optional().isInt({ min: 1 }),
|
||||
query('checkIn').optional().isISO8601(),
|
||||
query('checkOut').optional().isISO8601(),
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
limit = 12,
|
||||
type,
|
||||
category, // category slug or ID
|
||||
maxPrice,
|
||||
minPrice,
|
||||
guests,
|
||||
checkIn,
|
||||
checkOut,
|
||||
sortBy = 'name',
|
||||
sortOrder = 'asc'
|
||||
} = req.query;
|
||||
|
||||
// Build filter object
|
||||
let filter = {
|
||||
isActive: true,
|
||||
status: 'Available'
|
||||
};
|
||||
|
||||
if (type) filter.type = type;
|
||||
if (guests) filter.maxOccupancy = { $gte: parseInt(guests) };
|
||||
|
||||
// Handle category filtering
|
||||
if (category) {
|
||||
// Try to find category by slug first, then by ID
|
||||
const categoryDoc = await RoomCategory.findOne({
|
||||
$or: [
|
||||
{ slug: category },
|
||||
{ _id: category }
|
||||
]
|
||||
});
|
||||
|
||||
if (categoryDoc) {
|
||||
filter.category = categoryDoc._id;
|
||||
} else {
|
||||
// If category not found, return empty results
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
rooms: [],
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages: 0,
|
||||
totalCount: 0,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Price filtering (using virtual currentPrice would require aggregation)
|
||||
if (minPrice || maxPrice) {
|
||||
filter.basePrice = {};
|
||||
if (minPrice) filter.basePrice.$gte = parseFloat(minPrice);
|
||||
if (maxPrice) filter.basePrice.$lte = parseFloat(maxPrice);
|
||||
}
|
||||
|
||||
// If dates are provided, find available rooms
|
||||
let roomQuery;
|
||||
if (checkIn && checkOut) {
|
||||
const checkInDate = new Date(checkIn);
|
||||
const checkOutDate = new Date(checkOut);
|
||||
|
||||
if (checkInDate >= checkOutDate) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Check-out date must be after check-in date'
|
||||
});
|
||||
}
|
||||
|
||||
roomQuery = Room.findAvailable(checkInDate, checkOutDate, guests ? parseInt(guests) : 1);
|
||||
} else {
|
||||
roomQuery = Room.find(filter);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
const sortOptions = {};
|
||||
sortOptions[sortBy] = sortOrder === 'desc' ? -1 : 1;
|
||||
|
||||
// Execute query with pagination
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const [rooms, totalCount] = await Promise.all([
|
||||
roomQuery
|
||||
.sort(sortOptions)
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit)),
|
||||
|
||||
checkIn && checkOut ?
|
||||
Room.findAvailable(new Date(checkIn), new Date(checkOut), guests ? parseInt(guests) : 1).countDocuments() :
|
||||
Room.countDocuments(filter)
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / parseInt(limit));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
rooms,
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages,
|
||||
totalCount,
|
||||
hasNextPage: parseInt(page) < totalPages,
|
||||
hasPrevPage: parseInt(page) > 1
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching rooms:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching rooms'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/rooms/:id
|
||||
// @desc Get single room by ID
|
||||
// @access Public
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const room = await Room.findById(req.params.id);
|
||||
|
||||
if (!room || !room.isActive) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Room not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: room
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching room:', error);
|
||||
|
||||
if (error.name === 'CastError') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid room ID format'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching room'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/rooms/slug/:slug
|
||||
// @desc Get single room by slug
|
||||
// @access Public
|
||||
router.get('/slug/:slug', async (req, res) => {
|
||||
try {
|
||||
const room = await Room.findOne({
|
||||
slug: req.params.slug,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Room not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: room
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching room by slug:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching room'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/rooms/:id/availability
|
||||
// @desc Check room availability for specific dates
|
||||
// @access Public
|
||||
router.post('/:id/availability', [
|
||||
body('checkIn').isISO8601().withMessage('Valid check-in date is required'),
|
||||
body('checkOut').isISO8601().withMessage('Valid check-out date is required'),
|
||||
body('guests').optional().isInt({ min: 1 }).withMessage('Number of guests must be at least 1')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { checkIn, checkOut, guests = 1 } = req.body;
|
||||
const checkInDate = new Date(checkIn);
|
||||
const checkOutDate = new Date(checkOut);
|
||||
|
||||
if (checkInDate >= checkOutDate) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Check-out date must be after check-in date'
|
||||
});
|
||||
}
|
||||
|
||||
if (checkInDate < new Date()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Check-in date cannot be in the past'
|
||||
});
|
||||
}
|
||||
|
||||
const room = await Room.findById(req.params.id);
|
||||
|
||||
if (!room || !room.isActive) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Room not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (room.maxOccupancy < guests) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Room can accommodate maximum ${room.maxOccupancy} guests`
|
||||
});
|
||||
}
|
||||
|
||||
const isAvailable = await room.isAvailable(checkInDate, checkOutDate);
|
||||
|
||||
// Calculate pricing
|
||||
const numberOfNights = Math.ceil((checkOutDate - checkInDate) / (1000 * 60 * 60 * 24));
|
||||
const roomRate = room.currentPrice;
|
||||
const subtotal = roomRate * numberOfNights;
|
||||
const taxes = subtotal * 0.12; // 12% tax
|
||||
const total = subtotal + taxes;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
available: isAvailable,
|
||||
room: {
|
||||
id: room._id,
|
||||
name: room.name,
|
||||
type: room.type,
|
||||
maxOccupancy: room.maxOccupancy
|
||||
},
|
||||
pricing: {
|
||||
roomRate,
|
||||
numberOfNights,
|
||||
subtotal,
|
||||
taxes,
|
||||
total
|
||||
},
|
||||
dates: {
|
||||
checkIn: checkInDate,
|
||||
checkOut: checkOutDate
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking availability:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while checking availability'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/rooms/types/available
|
||||
// @desc Get available room types with counts
|
||||
// @access Public
|
||||
router.get('/types/available', [
|
||||
query('checkIn').optional().isISO8601(),
|
||||
query('checkOut').optional().isISO8601(),
|
||||
query('guests').optional().isInt({ min: 1 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const { checkIn, checkOut, guests = 1 } = req.query;
|
||||
|
||||
let aggregationPipeline = [
|
||||
{
|
||||
$match: {
|
||||
isActive: true,
|
||||
status: 'Available',
|
||||
maxOccupancy: { $gte: parseInt(guests) }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// If dates provided, filter by availability
|
||||
if (checkIn && checkOut) {
|
||||
const checkInDate = new Date(checkIn);
|
||||
const checkOutDate = new Date(checkOut);
|
||||
|
||||
aggregationPipeline.push(
|
||||
{
|
||||
$lookup: {
|
||||
from: 'bookings',
|
||||
let: { roomId: '$_id' },
|
||||
pipeline: [
|
||||
{
|
||||
$match: {
|
||||
$expr: { $eq: ['$room', '$$roomId'] },
|
||||
status: { $in: ['Confirmed', 'Checked In'] },
|
||||
$or: [
|
||||
{
|
||||
checkInDate: { $lt: checkOutDate },
|
||||
checkOutDate: { $gt: checkInDate }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
as: 'conflictingBookings'
|
||||
}
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
conflictingBookings: { $size: 0 }
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
aggregationPipeline.push(
|
||||
{
|
||||
$group: {
|
||||
_id: '$type',
|
||||
count: { $sum: 1 },
|
||||
minPrice: { $min: '$basePrice' },
|
||||
maxPrice: { $max: '$basePrice' },
|
||||
avgPrice: { $avg: '$basePrice' },
|
||||
rooms: {
|
||||
$push: {
|
||||
id: '$_id',
|
||||
name: '$name',
|
||||
price: '$basePrice',
|
||||
amenities: '$amenities',
|
||||
images: { $slice: ['$images', 1] }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { minPrice: 1 }
|
||||
}
|
||||
);
|
||||
|
||||
const roomTypes = await Room.aggregate(aggregationPipeline);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: roomTypes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching room types:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while fetching room types'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Protected routes below (require authentication)
|
||||
|
||||
// @route POST /api/rooms
|
||||
// @desc Create a new room (Admin only)
|
||||
// @access Private/Admin
|
||||
router.post('/', adminAuth, [
|
||||
body('name').notEmpty().withMessage('Room name is required'),
|
||||
body('type').isIn(['Standard', 'Deluxe', 'Suite', 'Executive Suite', 'Presidential Suite']),
|
||||
body('description').notEmpty().withMessage('Description is required'),
|
||||
body('roomNumber').notEmpty().withMessage('Room number is required'),
|
||||
body('floor').isInt({ min: 1 }).withMessage('Floor must be a positive integer'),
|
||||
body('size').isFloat({ min: 1 }).withMessage('Size must be a positive number'),
|
||||
body('maxOccupancy').isInt({ min: 1, max: 8 }).withMessage('Max occupancy must be between 1 and 8'),
|
||||
body('basePrice').isFloat({ min: 0 }).withMessage('Base price must be a positive number'),
|
||||
body('bedType').isIn(['Single', 'Double', 'Queen', 'King', 'Twin', 'Sofa Bed']),
|
||||
body('bedCount').isInt({ min: 1 }).withMessage('Bed count must be at least 1')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation errors',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
// Check if room number already exists
|
||||
const existingRoom = await Room.findOne({ roomNumber: req.body.roomNumber });
|
||||
if (existingRoom) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Room number already exists'
|
||||
});
|
||||
}
|
||||
|
||||
const room = new Room(req.body);
|
||||
await room.save();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Room created successfully',
|
||||
data: room
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating room:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while creating room'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/rooms/:id
|
||||
// @desc Update room (Admin only)
|
||||
// @access Private/Admin
|
||||
router.put('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const room = await Room.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
req.body,
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Room not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Room updated successfully',
|
||||
data: room
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating room:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while updating room'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/rooms/:id
|
||||
// @desc Delete room (Admin only)
|
||||
// @access Private/Admin
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const room = await Room.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
{ isActive: false },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Room not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Room deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting room:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error while deleting room'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
208
routes/settings.js
Normal file
208
routes/settings.js
Normal file
@@ -0,0 +1,208 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const SiteSettings = require('../models/SiteSettings');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
|
||||
// @route GET /api/settings
|
||||
// @desc Get site settings
|
||||
// @access Public
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const settings = await SiteSettings.getSiteSettings();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { settings }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get settings error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching settings'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/settings
|
||||
// @desc Update site settings
|
||||
// @access Private (Admin)
|
||||
router.put('/', adminAuth, async (req, res) => {
|
||||
try {
|
||||
let settings = await SiteSettings.findOne();
|
||||
|
||||
if (!settings) {
|
||||
settings = new SiteSettings(req.body);
|
||||
} else {
|
||||
// Update all provided fields
|
||||
Object.keys(req.body).forEach(key => {
|
||||
if (key !== '_id' && key !== '__v') {
|
||||
if (typeof req.body[key] === 'object' && !Array.isArray(req.body[key]) && req.body[key] !== null) {
|
||||
// Deep merge for nested objects
|
||||
settings[key] = { ...settings[key], ...req.body[key] };
|
||||
} else {
|
||||
settings[key] = req.body[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (req.admin && req.admin.id) {
|
||||
settings.lastModifiedBy = req.admin.id;
|
||||
}
|
||||
await settings.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Settings updated successfully',
|
||||
data: { settings }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update settings error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error updating settings'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/settings/public
|
||||
// @desc Get public settings (subset for frontend)
|
||||
// @access Public
|
||||
router.get('/public', async (req, res) => {
|
||||
try {
|
||||
const settings = await SiteSettings.getSiteSettings();
|
||||
|
||||
// Return only public-facing settings
|
||||
const publicSettings = {
|
||||
hotel: {
|
||||
name: settings.hotel.name,
|
||||
tagline: settings.hotel.tagline,
|
||||
phone: settings.hotel.phone,
|
||||
email: settings.hotel.email,
|
||||
whatsapp: settings.hotel.whatsapp,
|
||||
address: settings.hotel.address,
|
||||
socialMedia: settings.hotel.socialMedia,
|
||||
businessHours: settings.hotel.businessHours
|
||||
},
|
||||
theme: settings.theme,
|
||||
features: settings.features,
|
||||
languages: settings.languages,
|
||||
booking: {
|
||||
enabled: settings.booking.enabled,
|
||||
minNights: settings.booking.minNights,
|
||||
maxNights: settings.booking.maxNights,
|
||||
currency: settings.booking.currency,
|
||||
currencySymbol: settings.booking.currencySymbol
|
||||
}
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { settings: publicSettings }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get public settings error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching settings'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/settings/hotel
|
||||
// @desc Update hotel information
|
||||
// @access Private (Admin)
|
||||
router.put('/hotel', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = await SiteSettings.getSiteSettings();
|
||||
|
||||
settings.hotel = { ...settings.hotel, ...req.body };
|
||||
settings.lastModifiedBy = req.admin.id;
|
||||
await settings.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Hotel information updated successfully',
|
||||
data: { hotel: settings.hotel }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update hotel info error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error updating hotel information'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/settings/theme
|
||||
// @desc Update theme settings
|
||||
// @access Private (Admin)
|
||||
router.put('/theme', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = await SiteSettings.getSiteSettings();
|
||||
|
||||
settings.theme = { ...settings.theme, ...req.body };
|
||||
if (req.body.colors) {
|
||||
settings.theme.colors = { ...settings.theme.colors, ...req.body.colors };
|
||||
}
|
||||
if (req.body.fonts) {
|
||||
settings.theme.fonts = { ...settings.theme.fonts, ...req.body.fonts };
|
||||
}
|
||||
if (req.body.layout) {
|
||||
settings.theme.layout = { ...settings.theme.layout, ...req.body.layout };
|
||||
}
|
||||
|
||||
settings.lastModifiedBy = req.admin.id;
|
||||
await settings.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Theme updated successfully',
|
||||
data: { theme: settings.theme }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update theme error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error updating theme'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/settings/maintenance
|
||||
// @desc Toggle maintenance mode
|
||||
// @access Private (Admin)
|
||||
router.put('/maintenance', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = await SiteSettings.getSiteSettings();
|
||||
const { enabled, message, allowedIPs } = req.body;
|
||||
|
||||
if (typeof enabled !== 'undefined') {
|
||||
settings.maintenance.enabled = enabled;
|
||||
}
|
||||
if (message) {
|
||||
settings.maintenance.message = message;
|
||||
}
|
||||
if (allowedIPs) {
|
||||
settings.maintenance.allowedIPs = allowedIPs;
|
||||
}
|
||||
|
||||
settings.lastModifiedBy = req.admin.id;
|
||||
await settings.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Maintenance mode updated successfully',
|
||||
data: { maintenance: settings.maintenance }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update maintenance mode error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error updating maintenance mode'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
188
routes/upload.js
Normal file
188
routes/upload.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
const adminAuth = require('../middleware/adminAuth');
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const uploadsDir = path.join(__dirname, '../../client/public/uploads');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Configure multer for file upload
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadsDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Generate unique filename
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const ext = path.extname(file.originalname);
|
||||
const name = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
cb(null, `${name}-${uniqueSuffix}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
// Accept images only
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed'), false);
|
||||
}
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB limit
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/upload
|
||||
// @desc Upload single or multiple images
|
||||
// @access Private (Admin)
|
||||
router.post('/', adminAuth, upload.array('images', 10), async (req, res) => {
|
||||
try {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'No files uploaded'
|
||||
});
|
||||
}
|
||||
|
||||
// Process uploaded files
|
||||
const uploadedFiles = await Promise.all(
|
||||
req.files.map(async (file) => {
|
||||
try {
|
||||
// Optimize image with sharp
|
||||
const optimizedPath = path.join(uploadsDir, `optimized-${file.filename}`);
|
||||
|
||||
await sharp(file.path)
|
||||
.resize(1920, 1080, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.jpeg({ quality: 85 })
|
||||
.toFile(optimizedPath);
|
||||
|
||||
// Replace original with optimized
|
||||
fs.unlinkSync(file.path);
|
||||
fs.renameSync(optimizedPath, file.path);
|
||||
|
||||
return {
|
||||
filename: file.filename,
|
||||
originalName: file.originalname,
|
||||
url: `/uploads/${file.filename}`,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
uploadedAt: new Date()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing image:', error);
|
||||
return {
|
||||
filename: file.filename,
|
||||
originalName: file.originalname,
|
||||
url: `/uploads/${file.filename}`,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
uploadedAt: new Date(),
|
||||
error: 'Optimization failed'
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Files uploaded successfully',
|
||||
data: { files: uploadedFiles }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || 'Error uploading files'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/upload/list
|
||||
// @desc Get list of uploaded files
|
||||
// @access Private (Admin)
|
||||
router.get('/list', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const files = fs.readdirSync(uploadsDir);
|
||||
|
||||
const fileList = files
|
||||
.filter(file => !file.startsWith('.')) // Ignore hidden files
|
||||
.map(filename => {
|
||||
const filePath = path.join(uploadsDir, filename);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
return {
|
||||
filename,
|
||||
url: `/uploads/${filename}`,
|
||||
size: stats.size,
|
||||
uploadedAt: stats.mtime
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.uploadedAt - a.uploadedAt); // Sort by newest first
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { files: fileList, total: fileList.length }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('List files error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error listing files'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/upload/:filename
|
||||
// @desc Delete an uploaded file
|
||||
// @access Private (Admin)
|
||||
router.delete('/:filename', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
const filePath = path.join(uploadsDir, filename);
|
||||
|
||||
// Security check: ensure filename doesn't contain path traversal
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid filename'
|
||||
});
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'File not found'
|
||||
});
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'File deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete file error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error deleting file'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
68
scripts/seedAdmin.js
Normal file
68
scripts/seedAdmin.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const mongoose = require('mongoose');
|
||||
const Admin = require('../models/Admin');
|
||||
require('dotenv').config();
|
||||
|
||||
const seedAdmin = async () => {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/oldvinehotel', {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
});
|
||||
|
||||
console.log('Connected to MongoDB');
|
||||
|
||||
// Check if super admin already exists
|
||||
const existingAdmin = await Admin.findOne({ isSuperAdmin: true });
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log('Super admin already exists:');
|
||||
console.log('Username:', existingAdmin.username);
|
||||
console.log('Email:', existingAdmin.email);
|
||||
await mongoose.connection.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create default super admin
|
||||
const defaultAdmin = new Admin({
|
||||
username: 'admin',
|
||||
email: 'admin@oldvinehotel.com',
|
||||
password: 'Admin@123456', // Change this in production!
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
role: 'super-admin',
|
||||
isSuperAdmin: true,
|
||||
permissions: [
|
||||
'manage_content',
|
||||
'manage_rooms',
|
||||
'manage_bookings',
|
||||
'manage_users',
|
||||
'manage_blog',
|
||||
'manage_gallery',
|
||||
'manage_settings',
|
||||
'view_analytics',
|
||||
'manage_admins'
|
||||
]
|
||||
});
|
||||
|
||||
await defaultAdmin.save();
|
||||
|
||||
console.log('\n✅ Super admin created successfully!');
|
||||
console.log('\n📝 Login Credentials:');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('Username: admin');
|
||||
console.log('Email: admin@oldvinehotel.com');
|
||||
console.log('Password: Admin@123456');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('\n⚠️ Please change the password after first login!');
|
||||
console.log('\n🌐 Admin Panel: http://localhost:3060/admin/login\n');
|
||||
|
||||
await mongoose.connection.close();
|
||||
} catch (error) {
|
||||
console.error('Error seeding admin:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
seedAdmin();
|
||||
|
||||
305
scripts/seedContent.js
Normal file
305
scripts/seedContent.js
Normal file
@@ -0,0 +1,305 @@
|
||||
const mongoose = require('mongoose');
|
||||
const Content = require('../models/Content');
|
||||
const Room = require('../models/Room');
|
||||
const SiteSettings = require('../models/SiteSettings');
|
||||
require('dotenv').config();
|
||||
|
||||
const seedContent = async () => {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/oldvinehotel', {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
});
|
||||
|
||||
console.log('Connected to MongoDB');
|
||||
|
||||
// ==================== HOMEPAGE CONTENT ====================
|
||||
const homePageExists = await Content.findOne({ page: 'home' });
|
||||
if (!homePageExists) {
|
||||
await Content.create({
|
||||
page: 'home',
|
||||
hero: {
|
||||
title: 'Welcome to Old Vine Hotel',
|
||||
subtitle: 'Experience Luxury in the Heart of Old Damascus',
|
||||
description: 'Discover timeless elegance and authentic Syrian hospitality in our beautifully restored historic hotel.',
|
||||
backgroundImage: '/images/hero.jpg',
|
||||
ctaText: 'Explore Rooms',
|
||||
ctaLink: '/rooms'
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
sectionId: 'welcome',
|
||||
title: 'Your Home Away From Home',
|
||||
subtitle: 'Experience Damascus Like Never Before',
|
||||
content: 'Nestled in the historic heart of Old Damascus, Old Vine Hotel offers an unforgettable blend of traditional Syrian architecture and modern luxury. Each room tells a story, each corner whispers history, and every moment creates lasting memories.',
|
||||
order: 1,
|
||||
isActive: true,
|
||||
layout: 'full-width'
|
||||
},
|
||||
{
|
||||
sectionId: 'features',
|
||||
title: 'Exceptional Amenities',
|
||||
subtitle: 'Everything You Need for a Perfect Stay',
|
||||
content: 'From luxurious accommodations to world-class dining, every detail has been carefully crafted to ensure your comfort.',
|
||||
order: 2,
|
||||
isActive: true,
|
||||
layout: 'full-width',
|
||||
items: [
|
||||
{ icon: 'wifi', title: 'Free Wi-Fi', description: 'High-speed internet throughout the hotel' },
|
||||
{ icon: 'restaurant', title: 'Fine Dining', description: 'Authentic Syrian and international cuisine' },
|
||||
{ icon: 'spa', title: 'Spa & Wellness', description: 'Relax and rejuvenate in our spa' },
|
||||
{ icon: 'concierge', title: '24/7 Concierge', description: 'Personalized service anytime' }
|
||||
]
|
||||
}
|
||||
],
|
||||
seo: {
|
||||
title: 'Old Vine Hotel - Luxury Hotel in Old Damascus',
|
||||
description: 'Experience timeless elegance and authentic Syrian hospitality at Old Vine Hotel, a beautifully restored historic hotel in the heart of Old Damascus.',
|
||||
keywords: ['damascus hotel', 'old damascus', 'luxury hotel syria', 'boutique hotel damascus'],
|
||||
ogImage: '/images/hero.jpg'
|
||||
},
|
||||
isPublished: true,
|
||||
publishedAt: new Date()
|
||||
});
|
||||
console.log('✅ Created homepage content');
|
||||
} else {
|
||||
console.log('ℹ️ Homepage content already exists');
|
||||
}
|
||||
|
||||
// ==================== ABOUT PAGE CONTENT ====================
|
||||
const aboutPageExists = await Content.findOne({ page: 'about' });
|
||||
if (!aboutPageExists) {
|
||||
await Content.create({
|
||||
page: 'about',
|
||||
hero: {
|
||||
title: 'Our Story',
|
||||
subtitle: 'A Legacy of Hospitality Since Heritage',
|
||||
description: 'Discover the rich history and timeless charm of Old Vine Hotel',
|
||||
backgroundImage: '/images/about-hero.jpg'
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
sectionId: 'heritage',
|
||||
title: 'Our Heritage',
|
||||
content: 'Old Vine Hotel stands as a testament to the timeless beauty of Old Damascus. Built within the ancient walls of the historic city, our hotel preserves the architectural grandeur of traditional Damascene houses while offering contemporary comfort and luxury.\n\nEvery stone in our building has witnessed centuries of history, and we are honored to be custodians of this heritage. Our restoration project has carefully maintained the original character of the structure, from the intricate geometric patterns to the central courtyard that has welcomed guests for generations.',
|
||||
image: '/images/about.jpg',
|
||||
order: 1,
|
||||
isActive: true,
|
||||
layout: 'right-image'
|
||||
},
|
||||
{
|
||||
sectionId: 'mission',
|
||||
title: 'Our Mission',
|
||||
content: 'To provide an authentic Damascus experience that honors our cultural heritage while delivering world-class hospitality. We believe in creating meaningful connections between our guests and the rich tapestry of Syrian culture, history, and tradition.',
|
||||
order: 2,
|
||||
isActive: true,
|
||||
layout: 'left-image'
|
||||
},
|
||||
{
|
||||
sectionId: 'vision',
|
||||
title: 'Our Vision',
|
||||
content: 'To be the premier destination for travelers seeking an authentic cultural experience in Damascus. We strive to preserve and share the beauty of Old Damascus while setting new standards in hospitality and guest satisfaction.',
|
||||
order: 3,
|
||||
isActive: true,
|
||||
layout: 'full-width'
|
||||
},
|
||||
{
|
||||
sectionId: 'values',
|
||||
title: 'Our Values',
|
||||
content: 'We are guided by principles of excellence, authenticity, and respect for our heritage.',
|
||||
order: 4,
|
||||
isActive: true,
|
||||
layout: 'full-width',
|
||||
items: [
|
||||
{ title: 'Authenticity', description: 'Preserving and celebrating Syrian culture and traditions' },
|
||||
{ title: 'Excellence', description: 'Delivering world-class service and hospitality' },
|
||||
{ title: 'Heritage', description: 'Honoring the history and architecture of Old Damascus' },
|
||||
{ title: 'Innovation', description: 'Blending tradition with modern comfort and technology' }
|
||||
]
|
||||
}
|
||||
],
|
||||
seo: {
|
||||
title: 'About Old Vine Hotel - Our Story and Heritage',
|
||||
description: 'Learn about Old Vine Hotel\'s rich history, mission, and commitment to preserving the cultural heritage of Old Damascus while providing exceptional hospitality.',
|
||||
keywords: ['damascus heritage', 'historic hotel damascus', 'old damascus architecture'],
|
||||
ogImage: '/images/about-hero.jpg'
|
||||
},
|
||||
isPublished: true,
|
||||
publishedAt: new Date()
|
||||
});
|
||||
console.log('✅ Created about page content');
|
||||
} else {
|
||||
console.log('ℹ️ About page content already exists');
|
||||
}
|
||||
|
||||
// ==================== ROOMS ====================
|
||||
// NOTE: Rooms need to match the actual Room schema which has specific requirements
|
||||
const deluxeExists = await Room.findOne({ roomNumber: '101' });
|
||||
if (!deluxeExists) {
|
||||
await Room.create({
|
||||
name: 'Deluxe Room',
|
||||
type: 'Deluxe',
|
||||
slug: 'deluxe-room',
|
||||
description: 'Experience comfort and elegance in our spacious Deluxe Room, featuring traditional Damascene decor with modern amenities. Perfect for couples or solo travelers seeking a blend of authentic charm and contemporary comfort.',
|
||||
shortDescription: 'Elegant comfort with traditional charm',
|
||||
roomNumber: '101',
|
||||
floor: 1,
|
||||
size: 35,
|
||||
maxOccupancy: 2,
|
||||
bedType: 'King',
|
||||
bedCount: 1,
|
||||
basePrice: 150,
|
||||
amenities: ['WiFi', 'TV', 'AC', 'Minibar', 'Safe', 'City View', 'Workspace'],
|
||||
images: [
|
||||
{ url: '/images/rooms/deluxe/01.jpg', alt: 'Deluxe Room - Bedroom', isPrimary: true },
|
||||
{ url: '/images/rooms/deluxe/02.jpg', alt: 'Deluxe Room - Bathroom', isPrimary: false },
|
||||
{ url: '/images/rooms/deluxe/03.jpg', alt: 'Deluxe Room - Seating Area', isPrimary: false },
|
||||
{ url: '/images/rooms/deluxe/04.jpg', alt: 'Deluxe Room - View', isPrimary: false }
|
||||
],
|
||||
status: 'Available',
|
||||
isActive: true,
|
||||
smokingAllowed: false,
|
||||
petsAllowed: false,
|
||||
cleaningStatus: 'Clean',
|
||||
metaTitle: 'Deluxe Room - Old Vine Hotel',
|
||||
metaDescription: 'Experience comfort and elegance in our spacious Deluxe Room with traditional Damascene decor.'
|
||||
});
|
||||
console.log('✅ Created Deluxe Room');
|
||||
} else {
|
||||
console.log('ℹ️ Deluxe Room already exists');
|
||||
}
|
||||
|
||||
const executiveExists = await Room.findOne({ roomNumber: '201' });
|
||||
if (!executiveExists) {
|
||||
await Room.create({
|
||||
name: 'Executive Suite',
|
||||
type: 'Executive Suite',
|
||||
slug: 'executive-suite',
|
||||
description: 'Indulge in luxury with our Executive Suite, offering separate living space, premium amenities, and stunning views of Old Damascus. Ideal for business travelers or those seeking extra space and comfort during their stay.',
|
||||
shortDescription: 'Premium luxury with separate living area',
|
||||
roomNumber: '201',
|
||||
floor: 2,
|
||||
size: 55,
|
||||
maxOccupancy: 3,
|
||||
bedType: 'King',
|
||||
bedCount: 1,
|
||||
basePrice: 250,
|
||||
amenities: ['WiFi', 'TV', 'AC', 'Minibar', 'Safe', 'City View', 'Workspace', 'Balcony', 'Jacuzzi'],
|
||||
images: [
|
||||
{ url: '/images/rooms/executive/01.jpg', alt: 'Executive Suite - Living Room', isPrimary: true },
|
||||
{ url: '/images/rooms/executive/02.jpg', alt: 'Executive Suite - Bedroom', isPrimary: false },
|
||||
{ url: '/images/rooms/executive/03.jpg', alt: 'Executive Suite - Bathroom', isPrimary: false },
|
||||
{ url: '/images/rooms/executive/04.jpg', alt: 'Executive Suite - View', isPrimary: false }
|
||||
],
|
||||
status: 'Available',
|
||||
isActive: true,
|
||||
smokingAllowed: false,
|
||||
petsAllowed: false,
|
||||
cleaningStatus: 'Clean',
|
||||
metaTitle: 'Executive Suite - Old Vine Hotel',
|
||||
metaDescription: 'Indulge in luxury with our Executive Suite offering separate living space and stunning views.'
|
||||
});
|
||||
console.log('✅ Created Executive Suite');
|
||||
} else {
|
||||
console.log('ℹ️ Executive Suite already exists');
|
||||
}
|
||||
|
||||
const presidentialExists = await Room.findOne({ roomNumber: '301' });
|
||||
if (!presidentialExists) {
|
||||
await Room.create({
|
||||
name: 'Presidential Suite',
|
||||
type: 'Presidential Suite',
|
||||
slug: 'presidential-suite',
|
||||
description: 'The epitome of luxury, our Presidential Suite offers unparalleled elegance, spacious living areas, and exclusive amenities for the most discerning guests. Experience the finest accommodation Old Damascus has to offer.',
|
||||
shortDescription: 'Ultimate luxury and exclusive service',
|
||||
roomNumber: '301',
|
||||
floor: 3,
|
||||
size: 85,
|
||||
maxOccupancy: 4,
|
||||
bedType: 'King',
|
||||
bedCount: 2,
|
||||
basePrice: 450,
|
||||
amenities: ['WiFi', 'TV', 'AC', 'Minibar', 'Safe', 'City View', 'Workspace', 'Balcony', 'Jacuzzi', 'Terrace', 'Walk-in Closet', 'Butler Service'],
|
||||
images: [
|
||||
{ url: '/images/rooms/presidential/01.jpg', alt: 'Presidential Suite - Main Living Area', isPrimary: true },
|
||||
{ url: '/images/rooms/presidential/02.jpg', alt: 'Presidential Suite - Master Bedroom', isPrimary: false },
|
||||
{ url: '/images/rooms/presidential/03.jpg', alt: 'Presidential Suite - Spa Bathroom', isPrimary: false },
|
||||
{ url: '/images/rooms/presidential/04.jpg', alt: 'Presidential Suite - Private Terrace', isPrimary: false }
|
||||
],
|
||||
status: 'Available',
|
||||
isActive: true,
|
||||
smokingAllowed: false,
|
||||
petsAllowed: false,
|
||||
cleaningStatus: 'Clean',
|
||||
metaTitle: 'Presidential Suite - Old Vine Hotel',
|
||||
metaDescription: 'The epitome of luxury with unparalleled elegance, spacious living areas, and exclusive amenities.'
|
||||
});
|
||||
console.log('✅ Created Presidential Suite');
|
||||
} else {
|
||||
console.log('ℹ️ Presidential Suite already exists');
|
||||
}
|
||||
|
||||
// ==================== SITE SETTINGS ====================
|
||||
let settings = await SiteSettings.findOne();
|
||||
if (!settings) {
|
||||
settings = await SiteSettings.create({
|
||||
siteName: 'Old Vine Hotel',
|
||||
siteDescription: 'Experience luxury and authentic Syrian hospitality in the heart of Old Damascus',
|
||||
siteKeywords: 'damascus hotel, old damascus, luxury hotel syria, boutique hotel damascus, historic hotel',
|
||||
contactEmail: 'info@oldvinehotel.com',
|
||||
contactPhone: '+963 986 703 070',
|
||||
whatsapp: '+963 986 703 070',
|
||||
address: {
|
||||
street: 'Old Damascus City',
|
||||
city: 'Damascus',
|
||||
country: 'Syria',
|
||||
coordinates: {
|
||||
lat: 33.5138,
|
||||
lng: 36.2765
|
||||
}
|
||||
},
|
||||
socialMedia: {
|
||||
facebook: 'https://facebook.com/oldvinehotel',
|
||||
instagram: 'https://instagram.com/oldvinehotel',
|
||||
twitter: 'https://twitter.com/oldvinehotel'
|
||||
},
|
||||
theme: {
|
||||
primaryColor: '#8B4513',
|
||||
secondaryColor: '#D4AF37',
|
||||
accentColor: '#2C5F2D'
|
||||
},
|
||||
bookingSettings: {
|
||||
minNights: 1,
|
||||
maxNights: 30,
|
||||
checkInTime: '14:00',
|
||||
checkOutTime: '12:00',
|
||||
cancellationPolicy: 'Free cancellation up to 48 hours before check-in'
|
||||
},
|
||||
seo: {
|
||||
metaTitle: 'Old Vine Hotel - Luxury Accommodation in Old Damascus',
|
||||
metaDescription: 'Experience timeless elegance and authentic Syrian hospitality at Old Vine Hotel, a beautifully restored historic hotel in the heart of Old Damascus.',
|
||||
ogImage: '/images/hero.jpg'
|
||||
}
|
||||
});
|
||||
console.log('✅ Created site settings');
|
||||
} else {
|
||||
console.log('ℹ️ Site settings already exist');
|
||||
}
|
||||
|
||||
console.log('\n✅✅✅ CONTENT SEEDING COMPLETED! ✅✅✅\n');
|
||||
console.log('📊 Summary:');
|
||||
console.log(' - Homepage content: Ready');
|
||||
console.log(' - About page content: Ready');
|
||||
console.log(' - Rooms (3): Deluxe (101), Executive (201), Presidential (301)');
|
||||
console.log(' - Site settings: Configured');
|
||||
console.log('\n🔗 Next: Public website will fetch from these entries\n');
|
||||
|
||||
await mongoose.connection.close();
|
||||
} catch (error) {
|
||||
console.error('Error seeding content:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
seedContent();
|
||||
75
scripts/seedGalleryCategories.js
Normal file
75
scripts/seedGalleryCategories.js
Normal file
@@ -0,0 +1,75 @@
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
|
||||
const mongoose = require('mongoose');
|
||||
const GalleryCategory = require('../models/GalleryCategory');
|
||||
|
||||
const seedGalleryCategories = async () => {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/vine_hotel');
|
||||
console.log('✅ Connected to MongoDB');
|
||||
|
||||
const categories = [
|
||||
{
|
||||
name: 'Hotel Gallery',
|
||||
slug: 'hotel-gallery',
|
||||
description: 'Explore the beautiful interiors, architecture, and spaces of Old Vine Hotel. From elegant rooms to stunning courtyards, discover the charm of our historic property.',
|
||||
shortDescription: 'Beautiful interiors, architecture, and spaces of our hotel',
|
||||
displayOrder: 1,
|
||||
isActive: true,
|
||||
images: [],
|
||||
metaTitle: 'Hotel Gallery - Old Vine Hotel',
|
||||
metaDescription: 'Explore the beautiful interiors and architecture of Old Vine Hotel in Old Damascus.'
|
||||
},
|
||||
{
|
||||
name: 'Restaurant Gallery',
|
||||
slug: 'restaurant-gallery',
|
||||
description: 'Take a visual journey through our restaurant and dining spaces. Experience the ambiance, cuisine presentations, and elegant settings where culinary excellence meets authentic Syrian hospitality.',
|
||||
shortDescription: 'Restaurant ambiance, cuisine, and dining experiences',
|
||||
displayOrder: 2,
|
||||
isActive: true,
|
||||
images: [],
|
||||
metaTitle: 'Restaurant Gallery - Old Vine Hotel',
|
||||
metaDescription: 'Explore our restaurant and dining spaces at Old Vine Hotel.'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert or update categories
|
||||
for (const categoryData of categories) {
|
||||
const existingCategory = await GalleryCategory.findOne({ slug: categoryData.slug });
|
||||
|
||||
if (existingCategory) {
|
||||
// Update existing category but preserve images
|
||||
Object.keys(categoryData).forEach(key => {
|
||||
if (key !== 'images' || categoryData.images.length > 0) {
|
||||
existingCategory[key] = categoryData[key];
|
||||
}
|
||||
});
|
||||
await existingCategory.save();
|
||||
console.log(`✅ Updated category: ${categoryData.name}`);
|
||||
} else {
|
||||
const category = new GalleryCategory(categoryData);
|
||||
await category.save();
|
||||
console.log(`✅ Created category: ${categoryData.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n📊 Gallery Categories Summary:');
|
||||
const allCategories = await GalleryCategory.find().sort({ displayOrder: 1 });
|
||||
allCategories.forEach(cat => {
|
||||
console.log(` • ${cat.name} (${cat.slug}) - ${cat.images.length} images`);
|
||||
});
|
||||
|
||||
console.log('\n✅ Gallery categories seeded successfully!');
|
||||
console.log('\n📝 Next Steps:');
|
||||
console.log(' 1. Upload images for each category');
|
||||
console.log(' 2. Images should be placed in: /client/public/images/gallery/[category-slug]/');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error seeding gallery categories:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
seedGalleryCategories();
|
||||
|
||||
108
scripts/seedRoomCategories.js
Normal file
108
scripts/seedRoomCategories.js
Normal file
@@ -0,0 +1,108 @@
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
|
||||
const mongoose = require('mongoose');
|
||||
const RoomCategory = require('../models/RoomCategory');
|
||||
|
||||
const seedRoomCategories = async () => {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/vine_hotel');
|
||||
console.log('✅ Connected to MongoDB');
|
||||
|
||||
// Clear existing categories (optional - comment out if you want to keep existing)
|
||||
// await RoomCategory.deleteMany({});
|
||||
// console.log('🗑️ Cleared existing categories');
|
||||
|
||||
const categories = [
|
||||
{
|
||||
name: 'Single Room',
|
||||
slug: 'single-room',
|
||||
description: 'Comfortable single rooms perfect for solo travelers. Each room is thoughtfully designed with modern amenities and traditional Damascene touches.',
|
||||
shortDescription: 'Perfect for solo travelers with modern amenities',
|
||||
displayOrder: 1,
|
||||
isActive: true,
|
||||
// Images will be added via CMS or manually
|
||||
// Placeholder structure - actual images should be uploaded via CMS
|
||||
images: [],
|
||||
features: ['WiFi', 'TV', 'AC', 'Minibar', 'Safe', 'City View'],
|
||||
metaTitle: 'Single Rooms - Old Vine Hotel',
|
||||
metaDescription: 'Comfortable single rooms perfect for solo travelers at Old Vine Hotel in Damascus.'
|
||||
},
|
||||
{
|
||||
name: 'Double Room',
|
||||
slug: 'double-room',
|
||||
description: 'Spacious double rooms ideal for couples or business travelers. Features comfortable double beds, elegant furnishings, and stunning views of Old Damascus.',
|
||||
shortDescription: 'Spacious rooms perfect for couples with elegant furnishings',
|
||||
displayOrder: 2,
|
||||
isActive: true,
|
||||
images: [],
|
||||
features: ['WiFi', 'TV', 'AC', 'Minibar', 'Safe', 'City View', 'Workspace', 'Balcony'],
|
||||
metaTitle: 'Double Rooms - Old Vine Hotel',
|
||||
metaDescription: 'Spacious double rooms with elegant furnishings and stunning views at Old Vine Hotel.'
|
||||
},
|
||||
{
|
||||
name: 'Suite Room',
|
||||
slug: 'suite-room',
|
||||
description: 'Luxurious suite rooms offering separate living areas, premium amenities, and exclusive services. Perfect for extended stays or special occasions.',
|
||||
shortDescription: 'Luxurious suites with separate living areas and premium amenities',
|
||||
displayOrder: 3,
|
||||
isActive: true,
|
||||
images: [],
|
||||
features: ['WiFi', 'TV', 'AC', 'Minibar', 'Safe', 'City View', 'Workspace', 'Balcony', 'Jacuzzi', 'Terrace'],
|
||||
metaTitle: 'Suite Rooms - Old Vine Hotel',
|
||||
metaDescription: 'Luxurious suite rooms with separate living areas at Old Vine Hotel in Damascus.'
|
||||
},
|
||||
{
|
||||
name: 'Twin Room',
|
||||
slug: 'twin-room',
|
||||
description: 'Comfortable twin rooms with two separate beds, ideal for friends or family traveling together. Features all modern amenities in a traditional setting.',
|
||||
shortDescription: 'Comfortable rooms with two beds, perfect for friends or family',
|
||||
displayOrder: 4,
|
||||
isActive: true,
|
||||
images: [],
|
||||
features: ['WiFi', 'TV', 'AC', 'Minibar', 'Safe', 'City View'],
|
||||
metaTitle: 'Twin Rooms - Old Vine Hotel',
|
||||
metaDescription: 'Comfortable twin rooms with two separate beds at Old Vine Hotel.'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert or update categories
|
||||
for (const categoryData of categories) {
|
||||
const existingCategory = await RoomCategory.findOne({ slug: categoryData.slug });
|
||||
|
||||
if (existingCategory) {
|
||||
// Update existing category but preserve images
|
||||
Object.keys(categoryData).forEach(key => {
|
||||
if (key !== 'images' || categoryData.images.length > 0) {
|
||||
existingCategory[key] = categoryData[key];
|
||||
}
|
||||
});
|
||||
await existingCategory.save();
|
||||
console.log(`✅ Updated category: ${categoryData.name}`);
|
||||
} else {
|
||||
const category = new RoomCategory(categoryData);
|
||||
await category.save();
|
||||
console.log(`✅ Created category: ${categoryData.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n📊 Room Categories Summary:');
|
||||
const allCategories = await RoomCategory.find().sort({ displayOrder: 1 });
|
||||
allCategories.forEach(cat => {
|
||||
console.log(` • ${cat.name} (${cat.slug}) - ${cat.images.length} images`);
|
||||
});
|
||||
|
||||
console.log('\n✅ Room categories seeded successfully!');
|
||||
console.log('\n📝 Next Steps:');
|
||||
console.log(' 1. Upload images for each category via CMS admin panel');
|
||||
console.log(' 2. Assign rooms to categories');
|
||||
console.log(' 3. Images should be placed in: /client/public/images/rooms/[category-slug]/');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error seeding room categories:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
seedRoomCategories();
|
||||
|
||||
51
scripts/updateAboutPage.js
Normal file
51
scripts/updateAboutPage.js
Normal file
@@ -0,0 +1,51 @@
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
|
||||
const mongoose = require('mongoose');
|
||||
const Content = require('../models/Content');
|
||||
|
||||
const updateAboutPage = async () => {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/vine_hotel');
|
||||
console.log('✅ Connected to MongoDB');
|
||||
|
||||
// Find and update about page content
|
||||
let aboutPage = await Content.findOne({ page: 'about' });
|
||||
|
||||
if (!aboutPage) {
|
||||
console.log('📝 About page not found, creating new one...');
|
||||
aboutPage = new Content({ page: 'about' });
|
||||
}
|
||||
|
||||
// Update heritage section
|
||||
const heritageSectionIndex = aboutPage.sections.findIndex(s => s.sectionId === 'heritage');
|
||||
|
||||
const newHeritageSection = {
|
||||
sectionId: 'heritage',
|
||||
title: 'A Hidden Gem Of Old Damascus',
|
||||
content: `Old Vine Hotel stands as a living piece of history, where centuries-old craftsmanship and modern elegance unite in perfect harmony. the property features three tranquil courtyards, each shaded by climbing vines and fragrant citrus trees, offering guests peaceful spaces to relax and unwind.
|
||||
|
||||
From the terraces overlooking old Damascus and the new city, the views are simply breathtaking. the majestic Umayyad mosque feels almost within reach, its minarets visible from the terrace—an unforgettable sight that connects you directly to the heart of one of the world's oldest continuously inhabited cities.`
|
||||
};
|
||||
|
||||
if (heritageSectionIndex !== -1) {
|
||||
aboutPage.sections[heritageSectionIndex] = newHeritageSection;
|
||||
} else {
|
||||
aboutPage.sections.push(newHeritageSection);
|
||||
}
|
||||
|
||||
await aboutPage.save();
|
||||
console.log('✅ About page heritage section updated successfully!');
|
||||
console.log('\n📝 Heritage Section:');
|
||||
const heritageSection = aboutPage.sections.find(s => s.sectionId === 'heritage');
|
||||
console.log('Title:', heritageSection.title);
|
||||
console.log('Content:', heritageSection.content.substring(0, 150) + '...');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating about page:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
updateAboutPage();
|
||||
|
||||
94
scripts/updateCategoryImages.js
Normal file
94
scripts/updateCategoryImages.js
Normal file
@@ -0,0 +1,94 @@
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
|
||||
const mongoose = require('mongoose');
|
||||
const RoomCategory = require('../models/RoomCategory');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const updateCategoryImages = async () => {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/vine_hotel');
|
||||
console.log('✅ Connected to MongoDB');
|
||||
|
||||
// Define categories and their directories
|
||||
const categories = [
|
||||
{ slug: 'single-room', name: 'Single Room' },
|
||||
{ slug: 'double-room', name: 'Double Room' },
|
||||
{ slug: 'suite-room', name: 'Suite Room' },
|
||||
{ slug: 'twin-room', name: 'Twin Room' }
|
||||
];
|
||||
|
||||
const imagesBasePath = path.join(__dirname, '../../client/public/images/rooms');
|
||||
|
||||
for (const categoryInfo of categories) {
|
||||
const categoryDir = path.join(imagesBasePath, categoryInfo.slug);
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(categoryDir)) {
|
||||
console.log(`⚠️ Directory not found: ${categoryDir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read all files in directory
|
||||
const files = fs.readdirSync(categoryDir)
|
||||
.filter(file => {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return ['.jpg', '.jpeg', '.png', '.webp'].includes(ext);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort by filename numerically (01.jpg, 02.jpg, etc.)
|
||||
const numA = parseInt(a.match(/\d+/)?.[0] || '0');
|
||||
const numB = parseInt(b.match(/\d+/)?.[0] || '0');
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`⚠️ No images found in ${categoryInfo.slug}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build images array
|
||||
const images = files.map((file, index) => ({
|
||||
url: `/images/rooms/${categoryInfo.slug}/${file}`,
|
||||
alt: `${categoryInfo.name} - Image ${index + 1}`,
|
||||
isPrimary: index === 0, // First image is primary
|
||||
order: index
|
||||
}));
|
||||
|
||||
// Find and update category
|
||||
const category = await RoomCategory.findOne({ slug: categoryInfo.slug });
|
||||
|
||||
if (!category) {
|
||||
console.log(`⚠️ Category not found in database: ${categoryInfo.slug}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update category with images
|
||||
category.images = images;
|
||||
await category.save();
|
||||
|
||||
console.log(`✅ Updated ${categoryInfo.name}: ${images.length} images`);
|
||||
console.log(` Primary image: ${images[0].url}`);
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n📊 Summary:');
|
||||
const allCategories = await RoomCategory.find().sort({ displayOrder: 1 });
|
||||
for (const cat of allCategories) {
|
||||
console.log(` • ${cat.name}: ${cat.images.length} images`);
|
||||
if (cat.images.length > 0) {
|
||||
const primary = cat.images.find(img => img.isPrimary) || cat.images[0];
|
||||
console.log(` Primary: ${primary.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ All category images updated successfully!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating category images:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
updateCategoryImages();
|
||||
|
||||
92
scripts/updateGalleryCategoryImages.js
Normal file
92
scripts/updateGalleryCategoryImages.js
Normal file
@@ -0,0 +1,92 @@
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
|
||||
const mongoose = require('mongoose');
|
||||
const GalleryCategory = require('../models/GalleryCategory');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const updateGalleryCategoryImages = async () => {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/vine_hotel');
|
||||
console.log('✅ Connected to MongoDB');
|
||||
|
||||
// Define categories and their directories
|
||||
const categories = [
|
||||
{ slug: 'hotel-gallery', name: 'Hotel Gallery' },
|
||||
{ slug: 'restaurant-gallery', name: 'Restaurant Gallery' }
|
||||
];
|
||||
|
||||
const imagesBasePath = path.join(__dirname, '../../client/public/images/gallery');
|
||||
|
||||
for (const categoryInfo of categories) {
|
||||
const categoryDir = path.join(imagesBasePath, categoryInfo.slug);
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(categoryDir)) {
|
||||
console.log(`⚠️ Directory not found: ${categoryDir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read all files in directory
|
||||
const files = fs.readdirSync(categoryDir)
|
||||
.filter(file => {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return ['.jpg', '.jpeg', '.png', '.webp'].includes(ext);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort by filename numerically (01.jpg, 02.jpg, etc.)
|
||||
const numA = parseInt(a.match(/\d+/)?.[0] || '0');
|
||||
const numB = parseInt(b.match(/\d+/)?.[0] || '0');
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`⚠️ No images found in ${categoryInfo.slug}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build images array
|
||||
const images = files.map((file, index) => ({
|
||||
url: `/images/gallery/${categoryInfo.slug}/${file}`,
|
||||
alt: `${categoryInfo.name} - Image ${index + 1}`,
|
||||
isPrimary: index === 0, // First image is primary
|
||||
order: index
|
||||
}));
|
||||
|
||||
// Find and update category
|
||||
const category = await GalleryCategory.findOne({ slug: categoryInfo.slug });
|
||||
|
||||
if (!category) {
|
||||
console.log(`⚠️ Category not found in database: ${categoryInfo.slug}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update category with images
|
||||
category.images = images;
|
||||
await category.save();
|
||||
|
||||
console.log(`✅ Updated ${categoryInfo.name}: ${images.length} images`);
|
||||
console.log(` Primary image: ${images[0].url}`);
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n📊 Summary:');
|
||||
const allCategories = await GalleryCategory.find().sort({ displayOrder: 1 });
|
||||
for (const cat of allCategories) {
|
||||
console.log(` • ${cat.name}: ${cat.images.length} images`);
|
||||
if (cat.images.length > 0) {
|
||||
const primary = cat.images.find(img => img.isPrimary) || cat.images[0];
|
||||
console.log(` Primary: ${primary.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ All gallery category images updated successfully!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating gallery category images:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
updateGalleryCategoryImages();
|
||||
|
||||
46
scripts/updateHeroContent.js
Normal file
46
scripts/updateHeroContent.js
Normal file
@@ -0,0 +1,46 @@
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
|
||||
const mongoose = require('mongoose');
|
||||
const Content = require('../models/Content');
|
||||
|
||||
const updateHeroContent = async () => {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/vine_hotel');
|
||||
console.log('✅ Connected to MongoDB');
|
||||
|
||||
// Find and update homepage content
|
||||
const homepage = await Content.findOne({ page: 'home' });
|
||||
|
||||
if (!homepage) {
|
||||
console.error('❌ Homepage content not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Update hero section
|
||||
homepage.hero = {
|
||||
title: 'Your Home Away From Home',
|
||||
subtitle: 'Experience Damascus Like Never Before',
|
||||
description: `Hidden within the winding alleys of the ancient city of Old Damascus, Old Vine Hotel is a 5-star boutique haven that captures the essence of Syria's timeless charm. Once three historic damascene homes, the properties have been lovingly restored and seamlessly connected to create an intimate sanctuary of 25 beautifully designed rooms and suites.
|
||||
|
||||
Each corner whispers stories of the past, where handcrafted wood, marble courtyards, and elegant fountains blend effortlessly with modern comfort and sophistication. from the moment you step inside, you are embraced by an atmosphere of serenity, authenticity, and understated luxury—a true reflection of Damascus at its beauty.`,
|
||||
backgroundImage: '/images/hero.jpg',
|
||||
ctaText: 'Explore Rooms',
|
||||
ctaLink: '/rooms'
|
||||
};
|
||||
|
||||
await homepage.save();
|
||||
console.log('✅ Hero content updated successfully!');
|
||||
console.log('\n📝 New content:');
|
||||
console.log('Title:', homepage.hero.title);
|
||||
console.log('Subtitle:', homepage.hero.subtitle);
|
||||
console.log('Description:', homepage.hero.description.substring(0, 100) + '...');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating hero content:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
updateHeroContent();
|
||||
|
||||
70
scripts/updateWelcomeSection.js
Normal file
70
scripts/updateWelcomeSection.js
Normal file
@@ -0,0 +1,70 @@
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
|
||||
const mongoose = require('mongoose');
|
||||
const Content = require('../models/Content');
|
||||
|
||||
const updateWelcomeSection = async () => {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/vine_hotel');
|
||||
console.log('✅ Connected to MongoDB');
|
||||
|
||||
// Find and update homepage content
|
||||
const homepage = await Content.findOne({ page: 'home' });
|
||||
|
||||
if (!homepage) {
|
||||
console.error('❌ Homepage content not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Revert hero section to original
|
||||
homepage.hero = {
|
||||
title: 'Your Home Away From Home',
|
||||
subtitle: 'Experience Damascus Like Never Before',
|
||||
description: 'Nestled in the historic heart of Old Damascus, Old Vine Hotel offers an unforgettable blend of traditional Syrian architecture and modern luxury. Each room tells a story, each corner whispers history, and every moment creates lasting memories.',
|
||||
backgroundImage: '/images/hero.jpg',
|
||||
ctaText: 'Explore Rooms',
|
||||
ctaLink: '/rooms'
|
||||
};
|
||||
|
||||
// Update welcome section (the area below the hero)
|
||||
const welcomeSectionIndex = homepage.sections.findIndex(s => s.sectionId === 'welcome');
|
||||
|
||||
if (welcomeSectionIndex !== -1) {
|
||||
homepage.sections[welcomeSectionIndex] = {
|
||||
sectionId: 'welcome',
|
||||
title: 'Your Home Away From Home',
|
||||
subtitle: 'Experience Damascus Like Never Before',
|
||||
content: `Hidden within the winding alleys of the ancient city of Old Damascus, Old Vine Hotel is a 5-star boutique haven that captures the essence of Syria's timeless charm. Once three historic damascene homes, the properties have been lovingly restored and seamlessly connected to create an intimate sanctuary of 25 beautifully designed rooms and suites.
|
||||
|
||||
Each corner whispers stories of the past, where handcrafted wood, marble courtyards, and elegant fountains blend effortlessly with modern comfort and sophistication. from the moment you step inside, you are embraced by an atmosphere of serenity, authenticity, and understated luxury—a true reflection of Damascus at its beauty.`
|
||||
};
|
||||
} else {
|
||||
// Add welcome section if it doesn't exist
|
||||
homepage.sections.push({
|
||||
sectionId: 'welcome',
|
||||
title: 'Your Home Away From Home',
|
||||
subtitle: 'Experience Damascus Like Never Before',
|
||||
content: `Hidden within the winding alleys of the ancient city of Old Damascus, Old Vine Hotel is a 5-star boutique haven that captures the essence of Syria's timeless charm. Once three historic damascene homes, the properties have been lovingly restored and seamlessly connected to create an intimate sanctuary of 25 beautifully designed rooms and suites.
|
||||
|
||||
Each corner whispers stories of the past, where handcrafted wood, marble courtyards, and elegant fountains blend effortlessly with modern comfort and sophistication. from the moment you step inside, you are embraced by an atmosphere of serenity, authenticity, and understated luxury—a true reflection of Damascus at its beauty.`
|
||||
});
|
||||
}
|
||||
|
||||
await homepage.save();
|
||||
console.log('✅ Hero section reverted to original!');
|
||||
console.log('✅ Welcome section updated successfully!');
|
||||
console.log('\n📝 Welcome Section:');
|
||||
const welcomeSection = homepage.sections.find(s => s.sectionId === 'welcome');
|
||||
console.log('Title:', welcomeSection.title);
|
||||
console.log('Subtitle:', welcomeSection.subtitle);
|
||||
console.log('Content:', welcomeSection.content.substring(0, 100) + '...');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating content:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
updateWelcomeSection();
|
||||
|
||||
51
services/BookingComService.js
Normal file
51
services/BookingComService.js
Normal file
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
|
||||
class BookingComService {
|
||||
async healthCheck() {
|
||||
return {
|
||||
status: 'connected',
|
||||
service: 'Booking.com',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async updateRates(roomType, rates, dates) {
|
||||
return { status: 'ok', roomType, dates };
|
||||
}
|
||||
|
||||
async updateAvailability(roomType, availability, dates) {
|
||||
return { status: 'ok', roomType, dates };
|
||||
}
|
||||
|
||||
async getBookings(start, end) {
|
||||
return [];
|
||||
}
|
||||
|
||||
async processNewBooking(webhookData) {
|
||||
return { processed: true, event: 'booking_created' };
|
||||
}
|
||||
|
||||
async processBookingModification(webhookData) {
|
||||
return { processed: true, event: 'booking_modified' };
|
||||
}
|
||||
|
||||
async processBookingCancellation(webhookData) {
|
||||
return { processed: true, event: 'booking_cancelled' };
|
||||
}
|
||||
|
||||
async getPerformanceData(start, end) {
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
totals: {
|
||||
bookings: 0,
|
||||
revenue: 0,
|
||||
cancellations: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BookingComService;
|
||||
|
||||
|
||||
43
services/ExpediaService.js
Normal file
43
services/ExpediaService.js
Normal file
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
|
||||
class ExpediaService {
|
||||
async healthCheck() {
|
||||
return {
|
||||
status: 'connected',
|
||||
service: 'Expedia',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async updateRates(roomType, rates, dates) {
|
||||
return { status: 'ok', roomType, dates };
|
||||
}
|
||||
|
||||
async updateAvailability(roomType, availability, dates) {
|
||||
return { status: 'ok', roomType, dates };
|
||||
}
|
||||
|
||||
async getBookings(start, end) {
|
||||
return [];
|
||||
}
|
||||
|
||||
async processWebhook(webhookData) {
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
async getPerformanceData(start, end) {
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
totals: {
|
||||
bookings: 0,
|
||||
revenue: 0,
|
||||
cancellations: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExpediaService;
|
||||
|
||||
|
||||
507
services/OperaPMSService.js
Normal file
507
services/OperaPMSService.js
Normal file
@@ -0,0 +1,507 @@
|
||||
const axios = require('axios');
|
||||
const xml2js = require('xml2js');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class OperaPMSService {
|
||||
constructor() {
|
||||
this.baseURL = process.env.OPERA_PMS_URL;
|
||||
this.username = process.env.OPERA_PMS_USERNAME;
|
||||
this.password = process.env.OPERA_PMS_PASSWORD;
|
||||
this.propertyCode = process.env.OPERA_PMS_PROPERTY_CODE;
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: 30000,
|
||||
auth: {
|
||||
username: this.username,
|
||||
password: this.password
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'Accept': 'application/xml'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generate XML request wrapper
|
||||
generateXMLRequest(requestType, data) {
|
||||
const builder = new xml2js.Builder({
|
||||
rootName: 'OTA_Request',
|
||||
xmldec: { version: '1.0', encoding: 'UTF-8' }
|
||||
});
|
||||
|
||||
const request = {
|
||||
'@': {
|
||||
'xmlns': 'http://www.opentravel.org/OTA/2003/05',
|
||||
'Version': '1.0',
|
||||
'TimeStamp': new Date().toISOString(),
|
||||
'Target': 'Production'
|
||||
},
|
||||
'POS': {
|
||||
'Source': {
|
||||
'@': {
|
||||
'ISOCurrency': 'USD',
|
||||
'ISOCountry': 'US'
|
||||
},
|
||||
'RequestorID': {
|
||||
'@': {
|
||||
'Type': '5',
|
||||
'ID': this.propertyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
...data
|
||||
};
|
||||
|
||||
return builder.buildObject(request);
|
||||
}
|
||||
|
||||
// Parse XML response
|
||||
async parseXMLResponse(xmlString) {
|
||||
const parser = new xml2js.Parser({ explicitArray: false });
|
||||
return parser.parseStringPromise(xmlString);
|
||||
}
|
||||
|
||||
// Get room availability from Opera PMS
|
||||
async getRoomAvailability(checkIn, checkOut, roomType = null) {
|
||||
try {
|
||||
const data = {
|
||||
'AvailRequestSegments': {
|
||||
'AvailRequestSegment': {
|
||||
'StayDateRange': {
|
||||
'@': {
|
||||
'Start': checkIn.toISOString().split('T')[0],
|
||||
'End': checkOut.toISOString().split('T')[0]
|
||||
}
|
||||
},
|
||||
'RoomStayCandidates': {
|
||||
'RoomStayCandidate': {
|
||||
'@': {
|
||||
'Quantity': '1'
|
||||
},
|
||||
'GuestCounts': {
|
||||
'GuestCount': {
|
||||
'@': {
|
||||
'AgeQualifyingCode': '10',
|
||||
'Count': '1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'HotelSearchCriteria': {
|
||||
'Criterion': {
|
||||
'HotelRef': {
|
||||
'@': {
|
||||
'HotelCode': this.propertyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const xmlRequest = this.generateXMLRequest('OTA_HotelAvailRQ', data);
|
||||
|
||||
logger.integrationLog('Sending availability request to Opera PMS', {
|
||||
checkIn: checkIn.toISOString(),
|
||||
checkOut: checkOut.toISOString(),
|
||||
roomType
|
||||
});
|
||||
|
||||
const response = await this.client.post('/availability', xmlRequest);
|
||||
const parsedResponse = await this.parseXMLResponse(response.data);
|
||||
|
||||
return this.processAvailabilityResponse(parsedResponse);
|
||||
} catch (error) {
|
||||
logger.error('Opera PMS availability request failed:', {
|
||||
error: error.message,
|
||||
checkIn,
|
||||
checkOut,
|
||||
roomType
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Process availability response
|
||||
processAvailabilityResponse(response) {
|
||||
try {
|
||||
const availabilityData = response.OTA_HotelAvailRS;
|
||||
|
||||
if (availabilityData.Errors) {
|
||||
throw new Error(`Opera PMS Error: ${availabilityData.Errors.Error.ShortText}`);
|
||||
}
|
||||
|
||||
const roomStays = availabilityData.RoomStays?.RoomStay || [];
|
||||
const availableRooms = Array.isArray(roomStays) ? roomStays : [roomStays];
|
||||
|
||||
return availableRooms.map(room => ({
|
||||
roomTypeCode: room.RoomTypes?.RoomType?.RoomTypeCode,
|
||||
roomDescription: room.RoomTypes?.RoomType?.RoomDescription?.Text,
|
||||
rateCode: room.RatePlans?.RatePlan?.RatePlanCode,
|
||||
baseRate: parseFloat(room.RoomRates?.RoomRate?.Rates?.Rate?.Base?.AmountAfterTax),
|
||||
currency: room.RoomRates?.RoomRate?.Rates?.Rate?.Base?.CurrencyCode,
|
||||
availability: parseInt(room.RoomTypes?.RoomType?.NumberOfUnits) || 1
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Error processing Opera PMS availability response:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Create reservation in Opera PMS
|
||||
async createReservation(booking) {
|
||||
try {
|
||||
const data = {
|
||||
'HotelReservations': {
|
||||
'HotelReservation': {
|
||||
'@': {
|
||||
'CreateDateTime': new Date().toISOString(),
|
||||
'LastModifyDateTime': new Date().toISOString()
|
||||
},
|
||||
'UniqueID': {
|
||||
'@': {
|
||||
'Type': '14',
|
||||
'ID': booking.bookingNumber
|
||||
}
|
||||
},
|
||||
'RoomStays': {
|
||||
'RoomStay': {
|
||||
'RoomTypes': {
|
||||
'RoomType': {
|
||||
'@': {
|
||||
'RoomTypeCode': booking.room.operaRoomId || booking.room.type
|
||||
}
|
||||
}
|
||||
},
|
||||
'RatePlans': {
|
||||
'RatePlan': {
|
||||
'@': {
|
||||
'RatePlanCode': 'RACK'
|
||||
}
|
||||
}
|
||||
},
|
||||
'RoomRates': {
|
||||
'RoomRate': {
|
||||
'Rates': {
|
||||
'Rate': {
|
||||
'@': {
|
||||
'EffectiveDate': booking.checkInDate.toISOString().split('T')[0],
|
||||
'ExpireDate': booking.checkOutDate.toISOString().split('T')[0]
|
||||
},
|
||||
'Base': {
|
||||
'@': {
|
||||
'AmountAfterTax': booking.roomRate,
|
||||
'CurrencyCode': 'USD'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'GuestCounts': {
|
||||
'GuestCount': {
|
||||
'@': {
|
||||
'AgeQualifyingCode': '10',
|
||||
'Count': booking.numberOfGuests.adults
|
||||
}
|
||||
}
|
||||
},
|
||||
'TimeSpan': {
|
||||
'@': {
|
||||
'Start': booking.checkInDate.toISOString().split('T')[0],
|
||||
'End': booking.checkOutDate.toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'ResGuests': {
|
||||
'ResGuest': {
|
||||
'Profiles': {
|
||||
'ProfileInfo': {
|
||||
'Profile': {
|
||||
'Customer': {
|
||||
'PersonName': {
|
||||
'GivenName': booking.guest.firstName,
|
||||
'Surname': booking.guest.lastName
|
||||
},
|
||||
'Telephone': {
|
||||
'@': {
|
||||
'PhoneNumber': booking.guest.phone
|
||||
}
|
||||
},
|
||||
'Email': {
|
||||
'@': {
|
||||
'EmailType': 'Primary'
|
||||
},
|
||||
'_': booking.guest.email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'ResGlobalInfo': {
|
||||
'HotelReservationIDs': {
|
||||
'HotelReservationID': {
|
||||
'@': {
|
||||
'ResID_Type': '14',
|
||||
'ResID_Value': booking.confirmationCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const xmlRequest = this.generateXMLRequest('OTA_HotelResRQ', data);
|
||||
|
||||
logger.integrationLog('Creating reservation in Opera PMS', {
|
||||
bookingNumber: booking.bookingNumber,
|
||||
confirmationCode: booking.confirmationCode,
|
||||
guestEmail: booking.guest.email
|
||||
});
|
||||
|
||||
const response = await this.client.post('/reservations', xmlRequest);
|
||||
const parsedResponse = await this.parseXMLResponse(response.data);
|
||||
|
||||
return this.processReservationResponse(parsedResponse, booking);
|
||||
} catch (error) {
|
||||
logger.error('Opera PMS reservation creation failed:', {
|
||||
error: error.message,
|
||||
bookingNumber: booking.bookingNumber
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Process reservation response
|
||||
processReservationResponse(response, booking) {
|
||||
try {
|
||||
const reservationData = response.OTA_HotelResRS;
|
||||
|
||||
if (reservationData.Errors) {
|
||||
throw new Error(`Opera PMS Error: ${reservationData.Errors.Error.ShortText}`);
|
||||
}
|
||||
|
||||
const operaConfirmationNumber = reservationData.HotelReservations?.HotelReservation?.UniqueID?.ID;
|
||||
|
||||
logger.integrationLog('Opera PMS reservation created successfully', {
|
||||
bookingNumber: booking.bookingNumber,
|
||||
operaConfirmationNumber
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
operaConfirmationNumber,
|
||||
message: 'Reservation created successfully in Opera PMS'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error processing Opera PMS reservation response:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel reservation in Opera PMS
|
||||
async cancelReservation(operaConfirmationNumber, reason = 'Guest cancellation') {
|
||||
try {
|
||||
const data = {
|
||||
'HotelReservations': {
|
||||
'HotelReservation': {
|
||||
'UniqueID': {
|
||||
'@': {
|
||||
'Type': '14',
|
||||
'ID': operaConfirmationNumber
|
||||
}
|
||||
},
|
||||
'ResStatus': {
|
||||
'@': {
|
||||
'ResStatus': 'Cancelled'
|
||||
}
|
||||
},
|
||||
'CancelPenalties': {
|
||||
'CancelPenalty': {
|
||||
'PenaltyDescription': {
|
||||
'Text': reason
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const xmlRequest = this.generateXMLRequest('OTA_CancelRQ', data);
|
||||
|
||||
logger.integrationLog('Cancelling reservation in Opera PMS', {
|
||||
operaConfirmationNumber,
|
||||
reason
|
||||
});
|
||||
|
||||
const response = await this.client.post('/cancellations', xmlRequest);
|
||||
const parsedResponse = await this.parseXMLResponse(response.data);
|
||||
|
||||
return this.processCancellationResponse(parsedResponse);
|
||||
} catch (error) {
|
||||
logger.error('Opera PMS cancellation failed:', {
|
||||
error: error.message,
|
||||
operaConfirmationNumber
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Process cancellation response
|
||||
processCancellationResponse(response) {
|
||||
try {
|
||||
const cancellationData = response.OTA_CancelRS;
|
||||
|
||||
if (cancellationData.Errors) {
|
||||
throw new Error(`Opera PMS Error: ${cancellationData.Errors.Error.ShortText}`);
|
||||
}
|
||||
|
||||
logger.integrationLog('Opera PMS reservation cancelled successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Reservation cancelled successfully in Opera PMS'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error processing Opera PMS cancellation response:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get guest profile from Opera PMS
|
||||
async getGuestProfile(email) {
|
||||
try {
|
||||
const data = {
|
||||
'ProfileReadRequest': {
|
||||
'ReadRequests': {
|
||||
'ProfileReadRequest': {
|
||||
'UniqueID': {
|
||||
'@': {
|
||||
'Type': '1',
|
||||
'ID': email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const xmlRequest = this.generateXMLRequest('OTA_ProfileReadRQ', data);
|
||||
const response = await this.client.post('/profiles', xmlRequest);
|
||||
const parsedResponse = await this.parseXMLResponse(response.data);
|
||||
|
||||
return this.processProfileResponse(parsedResponse);
|
||||
} catch (error) {
|
||||
logger.error('Opera PMS profile lookup failed:', {
|
||||
error: error.message,
|
||||
email
|
||||
});
|
||||
return null; // Guest profile not found is not an error
|
||||
}
|
||||
}
|
||||
|
||||
// Process profile response
|
||||
processProfileResponse(response) {
|
||||
try {
|
||||
const profileData = response.OTA_ProfileReadRS;
|
||||
|
||||
if (profileData.Errors) {
|
||||
return null; // Profile not found
|
||||
}
|
||||
|
||||
const profile = profileData.Profiles?.ProfileInfo?.Profile;
|
||||
|
||||
return {
|
||||
operaGuestId: profile.UniqueID?.ID,
|
||||
firstName: profile.Customer?.PersonName?.GivenName,
|
||||
lastName: profile.Customer?.PersonName?.Surname,
|
||||
email: profile.Customer?.Email?._,
|
||||
phone: profile.Customer?.Telephone?.PhoneNumber,
|
||||
vipStatus: profile.Customer?.VIP_Indicator === 'true'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error processing Opera PMS profile response:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync room inventory
|
||||
async syncRoomInventory() {
|
||||
try {
|
||||
logger.integrationLog('Starting room inventory sync with Opera PMS');
|
||||
|
||||
// This would typically fetch all room types and their current inventory
|
||||
// Implementation depends on specific Opera PMS setup
|
||||
|
||||
const data = {
|
||||
'InventoryRetrieveRequest': {
|
||||
'HotelCode': this.propertyCode,
|
||||
'DateRange': {
|
||||
'Start': new Date().toISOString().split('T')[0],
|
||||
'End': new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const xmlRequest = this.generateXMLRequest('OTA_HotelInvCountRQ', data);
|
||||
const response = await this.client.post('/inventory', xmlRequest);
|
||||
const parsedResponse = await this.parseXMLResponse(response.data);
|
||||
|
||||
return this.processInventoryResponse(parsedResponse);
|
||||
} catch (error) {
|
||||
logger.error('Opera PMS inventory sync failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Process inventory response
|
||||
processInventoryResponse(response) {
|
||||
try {
|
||||
const inventoryData = response.OTA_HotelInvCountRS;
|
||||
|
||||
if (inventoryData.Errors) {
|
||||
throw new Error(`Opera PMS Error: ${inventoryData.Errors.Error.ShortText}`);
|
||||
}
|
||||
|
||||
// Process inventory data and return room availability updates
|
||||
logger.integrationLog('Opera PMS inventory sync completed successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Inventory synchronized successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error processing Opera PMS inventory response:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Health check for Opera PMS connection
|
||||
async healthCheck() {
|
||||
try {
|
||||
const response = await this.client.get('/health');
|
||||
return {
|
||||
status: 'connected',
|
||||
responseTime: response.headers['x-response-time'] || 'unknown',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Opera PMS health check failed:', error);
|
||||
return {
|
||||
status: 'disconnected',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OperaPMSService;
|
||||
39
services/TripComService.js
Normal file
39
services/TripComService.js
Normal file
@@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
|
||||
class TripComService {
|
||||
async healthCheck() {
|
||||
return {
|
||||
status: 'connected',
|
||||
service: 'Trip.com',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async updateRates(roomType, rates, dates) {
|
||||
return { status: 'ok', roomType, dates };
|
||||
}
|
||||
|
||||
async updateAvailability(roomType, availability, dates) {
|
||||
return { status: 'ok', roomType, dates };
|
||||
}
|
||||
|
||||
async getBookings(start, end) {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getPerformanceData(start, end) {
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
totals: {
|
||||
bookings: 0,
|
||||
revenue: 0,
|
||||
cancellations: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TripComService;
|
||||
|
||||
|
||||
132
utils/logger.js
Normal file
132
utils/logger.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
|
||||
// Define custom log format
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
const fs = require('fs');
|
||||
const logDir = 'logs';
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir);
|
||||
}
|
||||
|
||||
// Create Winston logger
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
defaultMeta: { service: 'old-vine-hotel-api' },
|
||||
transports: [
|
||||
// Write all logs with level 'error' and below to 'error.log'
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
}),
|
||||
|
||||
// Write all logs with level 'info' and below to 'combined.log'
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'combined.log'),
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
}),
|
||||
|
||||
// Write only booking-related logs
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'bookings.log'),
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json(),
|
||||
winston.format((info) => {
|
||||
return info.type === 'booking' ? info : false;
|
||||
})()
|
||||
),
|
||||
}),
|
||||
|
||||
// Write only payment-related logs
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'payments.log'),
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json(),
|
||||
winston.format((info) => {
|
||||
return info.type === 'payment' ? info : false;
|
||||
})()
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// If we're not in production, log to the console as well
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple(),
|
||||
winston.format.printf(info => {
|
||||
return `${info.timestamp} [${info.level}]: ${info.message} ${info.stack ? '\n' + info.stack : ''}`;
|
||||
})
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
// Custom logging methods for specific contexts
|
||||
logger.bookingLog = (message, meta = {}) => {
|
||||
logger.info(message, { ...meta, type: 'booking' });
|
||||
};
|
||||
|
||||
logger.paymentLog = (message, meta = {}) => {
|
||||
logger.info(message, { ...meta, type: 'payment' });
|
||||
};
|
||||
|
||||
logger.integrationLog = (message, meta = {}) => {
|
||||
logger.info(message, { ...meta, type: 'integration' });
|
||||
};
|
||||
|
||||
logger.securityLog = (message, meta = {}) => {
|
||||
logger.warn(message, { ...meta, type: 'security' });
|
||||
};
|
||||
|
||||
// Error logging with context
|
||||
logger.logError = (error, context = {}) => {
|
||||
logger.error(error.message, {
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name
|
||||
},
|
||||
context
|
||||
});
|
||||
};
|
||||
|
||||
// Request logging middleware
|
||||
logger.requestLogger = (req, res, next) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
logger.info('HTTP Request', {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
userAgent: req.get('User-Agent'),
|
||||
ip: req.ip || req.connection.remoteAddress,
|
||||
type: 'http'
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = logger;
|
||||
284
utils/sendEmail.js
Normal file
284
utils/sendEmail.js
Normal file
@@ -0,0 +1,284 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const logger = require('./logger');
|
||||
|
||||
// Create transporter
|
||||
const createTransporter = () => {
|
||||
return nodemailer.createTransporter({
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: process.env.EMAIL_PORT,
|
||||
secure: false, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Email templates
|
||||
const generateBookingConfirmationHTML = (context) => {
|
||||
const { guest, booking, room } = context;
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.header { background: #8B4513; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; }
|
||||
.booking-details { background: #f9f9f9; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
||||
.footer { background: #f4f4f4; padding: 20px; text-align: center; font-size: 12px; }
|
||||
.btn { background: #D4AF37; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>The Old Vine Hotel</h1>
|
||||
<h2>Booking Confirmation</h2>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Dear ${guest.firstName} ${guest.lastName},</p>
|
||||
|
||||
<p>Thank you for choosing The Old Vine Hotel! We're delighted to confirm your reservation.</p>
|
||||
|
||||
<div class="booking-details">
|
||||
<h3>Booking Details</h3>
|
||||
<p><strong>Booking Number:</strong> ${booking.bookingNumber}</p>
|
||||
<p><strong>Confirmation Code:</strong> ${booking.confirmationCode}</p>
|
||||
<p><strong>Room:</strong> ${room.name} (${room.type})</p>
|
||||
<p><strong>Check-in:</strong> ${booking.checkInDate.toLocaleDateString()} (3:00 PM)</p>
|
||||
<p><strong>Check-out:</strong> ${booking.checkOutDate.toLocaleDateString()} (11:00 AM)</p>
|
||||
<p><strong>Guests:</strong> ${booking.numberOfGuests.adults} Adult(s)${booking.numberOfGuests.children ? `, ${booking.numberOfGuests.children} Child(ren)` : ''}</p>
|
||||
<p><strong>Nights:</strong> ${booking.numberOfNights}</p>
|
||||
<p><strong>Total Amount:</strong> $${booking.totalAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
|
||||
${booking.specialRequests ? `
|
||||
<div class="booking-details">
|
||||
<h3>Special Requests</h3>
|
||||
<p>${booking.specialRequests}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<h3>What to Expect</h3>
|
||||
<ul>
|
||||
<li>Luxury accommodations with premium amenities</li>
|
||||
<li>24/7 concierge service</li>
|
||||
<li>Complimentary WiFi throughout the hotel</li>
|
||||
<li>Fine dining restaurant and bar</li>
|
||||
<li>Spa and fitness center access</li>
|
||||
</ul>
|
||||
|
||||
<h3>Hotel Information</h3>
|
||||
<p>
|
||||
<strong>Address:</strong> 123 Luxury Avenue, Downtown District, City, State 12345<br>
|
||||
<strong>Phone:</strong> +1 (555) 123-4567<br>
|
||||
<strong>Email:</strong> info@oldvinehotel.com
|
||||
</p>
|
||||
|
||||
<p>If you need to modify or cancel your reservation, please contact us at least 24 hours in advance.</p>
|
||||
|
||||
<p>We look forward to welcoming you to The Old Vine Hotel!</p>
|
||||
|
||||
<p>Warm regards,<br>
|
||||
The Old Vine Hotel Team</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 The Old Vine Hotel. All rights reserved.</p>
|
||||
<p>This is an automated message. Please do not reply to this email.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
const generateBookingCancellationHTML = (context) => {
|
||||
const { guest, booking, room, cancellationFee, refundAmount } = context;
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.header { background: #8B4513; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; }
|
||||
.booking-details { background: #f9f9f9; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
||||
.footer { background: #f4f4f4; padding: 20px; text-align: center; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>The Old Vine Hotel</h1>
|
||||
<h2>Booking Cancellation</h2>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Dear ${guest.firstName} ${guest.lastName},</p>
|
||||
|
||||
<p>We have processed your cancellation request for the following booking:</p>
|
||||
|
||||
<div class="booking-details">
|
||||
<h3>Cancelled Booking Details</h3>
|
||||
<p><strong>Booking Number:</strong> ${booking.bookingNumber}</p>
|
||||
<p><strong>Room:</strong> ${room.name}</p>
|
||||
<p><strong>Check-in Date:</strong> ${booking.checkInDate.toLocaleDateString()}</p>
|
||||
<p><strong>Check-out Date:</strong> ${booking.checkOutDate.toLocaleDateString()}</p>
|
||||
<p><strong>Original Amount:</strong> $${booking.totalAmount.toFixed(2)}</p>
|
||||
${cancellationFee > 0 ? `<p><strong>Cancellation Fee:</strong> $${cancellationFee.toFixed(2)}</p>` : ''}
|
||||
<p><strong>Refund Amount:</strong> $${refundAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
|
||||
${refundAmount > 0 ? `
|
||||
<p>Your refund of $${refundAmount.toFixed(2)} will be processed within 5-7 business days and will appear on your original payment method.</p>
|
||||
` : ''}
|
||||
|
||||
<p>We're sorry to see you cancel your stay with us. We hope to welcome you to The Old Vine Hotel in the future.</p>
|
||||
|
||||
<p>If you have any questions about this cancellation, please don't hesitate to contact us.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
The Old Vine Hotel Team</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 The Old Vine Hotel. All rights reserved.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
const generateContactFormHTML = (context) => {
|
||||
const { name, email, phone, message } = context;
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.header { background: #8B4513; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; }
|
||||
.details { background: #f9f9f9; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>The Old Vine Hotel</h1>
|
||||
<h2>New Contact Form Submission</h2>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h3>Contact Details</h3>
|
||||
<div class="details">
|
||||
<p><strong>Name:</strong> ${name}</p>
|
||||
<p><strong>Email:</strong> ${email}</p>
|
||||
<p><strong>Phone:</strong> ${phone || 'Not provided'}</p>
|
||||
</div>
|
||||
|
||||
<h3>Message</h3>
|
||||
<div class="details">
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
|
||||
<p><em>This message was sent from the hotel website contact form.</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
// Main send email function
|
||||
const sendEmail = async ({ to, subject, template, context, html, text }) => {
|
||||
try {
|
||||
const transporter = createTransporter();
|
||||
|
||||
let emailHTML = html;
|
||||
|
||||
// Generate HTML based on template
|
||||
if (template && context) {
|
||||
switch (template) {
|
||||
case 'bookingConfirmation':
|
||||
emailHTML = generateBookingConfirmationHTML(context);
|
||||
break;
|
||||
case 'bookingCancellation':
|
||||
emailHTML = generateBookingCancellationHTML(context);
|
||||
break;
|
||||
case 'contactForm':
|
||||
emailHTML = generateContactFormHTML(context);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown email template: ${template}`);
|
||||
}
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: `"The Old Vine Hotel" <${process.env.EMAIL_FROM}>`,
|
||||
to,
|
||||
subject,
|
||||
html: emailHTML,
|
||||
text: text || '', // Plain text version
|
||||
};
|
||||
|
||||
const result = await transporter.sendMail(mailOptions);
|
||||
|
||||
logger.info(`Email sent successfully to ${to}`, {
|
||||
messageId: result.messageId,
|
||||
subject
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Email sending error:', {
|
||||
error: error.message,
|
||||
to,
|
||||
subject,
|
||||
template
|
||||
});
|
||||
|
||||
throw new Error(`Failed to send email: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Send bulk emails
|
||||
const sendBulkEmails = async (emails) => {
|
||||
const results = [];
|
||||
|
||||
for (const emailData of emails) {
|
||||
try {
|
||||
const result = await sendEmail(emailData);
|
||||
results.push({ success: true, to: emailData.to, messageId: result.messageId });
|
||||
} catch (error) {
|
||||
results.push({ success: false, to: emailData.to, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// Send newsletter
|
||||
const sendNewsletter = async (subscribers, subject, content) => {
|
||||
const emails = subscribers.map(subscriber => ({
|
||||
to: subscriber.email,
|
||||
subject,
|
||||
html: content,
|
||||
template: null
|
||||
}));
|
||||
|
||||
return sendBulkEmails(emails);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sendEmail,
|
||||
sendBulkEmails,
|
||||
sendNewsletter
|
||||
};
|
||||
Reference in New Issue
Block a user