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:
Talal Sharabi
2026-01-06 12:21:56 +04:00
commit a3308a26e2
48 changed files with 15294 additions and 0 deletions

49
.gitignore vendored Normal file
View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View 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
View 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
View 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>&copy; 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>&copy; 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
View 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
View 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
View 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>&copy; 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>&copy; 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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;

View 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
View 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;

View 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
View 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
View 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>&copy; 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>&copy; 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
};