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

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