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:
212
models/Admin.js
Normal file
212
models/Admin.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const adminSchema = new mongoose.Schema({
|
||||
// Basic information
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
lowercase: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
lowercase: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 8
|
||||
},
|
||||
|
||||
// Profile information
|
||||
firstName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
lastName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
avatar: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// Role and permissions
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['admin', 'super-admin', 'editor', 'manager'],
|
||||
default: 'admin'
|
||||
},
|
||||
permissions: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'manage_content', 'manage_rooms', 'manage_bookings',
|
||||
'manage_users', 'manage_blog', 'manage_gallery',
|
||||
'manage_settings', 'view_analytics', 'manage_admins'
|
||||
]
|
||||
}],
|
||||
|
||||
// Status
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isSuperAdmin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Security
|
||||
lastLogin: Date,
|
||||
loginAttempts: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lockUntil: Date,
|
||||
passwordResetToken: String,
|
||||
passwordResetExpires: Date,
|
||||
|
||||
// Session tracking
|
||||
currentSessions: [{
|
||||
token: String,
|
||||
createdAt: Date,
|
||||
expiresAt: Date,
|
||||
ipAddress: String,
|
||||
userAgent: String
|
||||
}]
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
transform: function(doc, ret) {
|
||||
delete ret.password;
|
||||
delete ret.passwordResetToken;
|
||||
delete ret.currentSessions;
|
||||
return ret;
|
||||
}
|
||||
},
|
||||
toObject: {
|
||||
virtuals: true,
|
||||
transform: function(doc, ret) {
|
||||
delete ret.password;
|
||||
delete ret.passwordResetToken;
|
||||
delete ret.currentSessions;
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Indexes
|
||||
adminSchema.index({ username: 1 });
|
||||
adminSchema.index({ email: 1 });
|
||||
adminSchema.index({ role: 1 });
|
||||
|
||||
// Virtual for full name
|
||||
adminSchema.virtual('fullName').get(function() {
|
||||
return `${this.firstName} ${this.lastName}`;
|
||||
});
|
||||
|
||||
// Virtual for account locked status
|
||||
adminSchema.virtual('isLocked').get(function() {
|
||||
return !!(this.lockUntil && this.lockUntil > Date.now());
|
||||
});
|
||||
|
||||
// Pre-save middleware to hash password
|
||||
adminSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Method to compare password
|
||||
adminSchema.methods.comparePassword = async function(candidatePassword) {
|
||||
try {
|
||||
return await bcrypt.compare(candidatePassword, this.password);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Method to increment login attempts
|
||||
adminSchema.methods.incLoginAttempts = function() {
|
||||
// If we have a previous lock that has expired, restart at 1
|
||||
if (this.lockUntil && this.lockUntil < Date.now()) {
|
||||
return this.updateOne({
|
||||
$set: { loginAttempts: 1 },
|
||||
$unset: { lockUntil: 1 }
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise increment
|
||||
const updates = { $inc: { loginAttempts: 1 } };
|
||||
|
||||
// Lock the account after 5 attempts for 2 hours
|
||||
const needsLock = this.loginAttempts + 1 >= 5 && !this.isLocked;
|
||||
if (needsLock) {
|
||||
updates.$set = { lockUntil: Date.now() + 2 * 60 * 60 * 1000 };
|
||||
}
|
||||
|
||||
return this.updateOne(updates);
|
||||
};
|
||||
|
||||
// Method to reset login attempts
|
||||
adminSchema.methods.resetLoginAttempts = function() {
|
||||
return this.updateOne({
|
||||
$set: { loginAttempts: 0 },
|
||||
$unset: { lockUntil: 1 }
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to find by credentials
|
||||
adminSchema.statics.findByCredentials = async function(username, password) {
|
||||
const admin = await this.findOne({
|
||||
$or: [{ username }, { email: username }],
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (admin.isLocked) {
|
||||
throw new Error('Account is temporarily locked. Please try again later.');
|
||||
}
|
||||
|
||||
const isMatch = await admin.comparePassword(password);
|
||||
|
||||
if (!isMatch) {
|
||||
await admin.incLoginAttempts();
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
// Reset login attempts on successful login
|
||||
if (admin.loginAttempts > 0) {
|
||||
await admin.resetLoginAttempts();
|
||||
}
|
||||
|
||||
// Update last login
|
||||
admin.lastLogin = new Date();
|
||||
await admin.save();
|
||||
|
||||
return admin;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Admin', adminSchema);
|
||||
|
||||
203
models/BlogPost.js
Normal file
203
models/BlogPost.js
Normal file
@@ -0,0 +1,203 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const blogPostSchema = new mongoose.Schema({
|
||||
// Post information
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true
|
||||
},
|
||||
excerpt: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 300
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Media
|
||||
featuredImage: {
|
||||
url: String,
|
||||
alt: String,
|
||||
caption: String
|
||||
},
|
||||
images: [{
|
||||
url: String,
|
||||
alt: String,
|
||||
caption: String
|
||||
}],
|
||||
|
||||
// Categorization
|
||||
category: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: [
|
||||
'News', 'Events', 'Travel Tips', 'Local Attractions',
|
||||
'Hotel Updates', 'Food & Dining', 'Spa & Wellness',
|
||||
'Special Offers', 'Guest Stories', 'Behind the Scenes'
|
||||
]
|
||||
},
|
||||
tags: [String],
|
||||
|
||||
// Author
|
||||
author: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin',
|
||||
required: true
|
||||
},
|
||||
|
||||
// Publishing
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'published', 'archived'],
|
||||
default: 'draft'
|
||||
},
|
||||
publishedAt: Date,
|
||||
scheduledPublishAt: Date,
|
||||
|
||||
// Engagement
|
||||
views: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
likes: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// SEO
|
||||
seo: {
|
||||
title: String,
|
||||
description: String,
|
||||
keywords: [String],
|
||||
ogImage: String,
|
||||
canonicalUrl: String
|
||||
},
|
||||
|
||||
// Features
|
||||
isFeatured: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowComments: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// Related content
|
||||
relatedPosts: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'BlogPost'
|
||||
}]
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
blogPostSchema.index({ slug: 1 });
|
||||
blogPostSchema.index({ status: 1, publishedAt: -1 });
|
||||
blogPostSchema.index({ category: 1 });
|
||||
blogPostSchema.index({ tags: 1 });
|
||||
blogPostSchema.index({ author: 1 });
|
||||
blogPostSchema.index({ isFeatured: 1 });
|
||||
|
||||
// Virtual for reading time (based on word count)
|
||||
blogPostSchema.virtual('readingTime').get(function() {
|
||||
const wordsPerMinute = 200;
|
||||
const wordCount = this.content.split(/\s+/).length;
|
||||
const minutes = Math.ceil(wordCount / wordsPerMinute);
|
||||
return minutes;
|
||||
});
|
||||
|
||||
// Virtual for is published
|
||||
blogPostSchema.virtual('isPublished').get(function() {
|
||||
return this.status === 'published' && this.publishedAt && this.publishedAt <= new Date();
|
||||
});
|
||||
|
||||
// Pre-save middleware to generate slug and handle publishing
|
||||
blogPostSchema.pre('save', function(next) {
|
||||
// Generate slug if modified or new
|
||||
if (this.isModified('title') || !this.slug) {
|
||||
this.slug = this.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
// Set published date when status changes to published
|
||||
if (this.isModified('status') && this.status === 'published' && !this.publishedAt) {
|
||||
this.publishedAt = new Date();
|
||||
}
|
||||
|
||||
// Generate SEO fields from content if not set
|
||||
if (!this.seo.title) {
|
||||
this.seo.title = this.title;
|
||||
}
|
||||
if (!this.seo.description) {
|
||||
this.seo.description = this.excerpt;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Static method to get published posts
|
||||
blogPostSchema.statics.getPublished = function(options = {}) {
|
||||
const {
|
||||
category,
|
||||
tag,
|
||||
limit = 10,
|
||||
skip = 0,
|
||||
featured = false
|
||||
} = options;
|
||||
|
||||
const query = {
|
||||
status: 'published',
|
||||
publishedAt: { $lte: new Date() }
|
||||
};
|
||||
|
||||
if (category) query.category = category;
|
||||
if (tag) query.tags = tag;
|
||||
if (featured) query.isFeatured = true;
|
||||
|
||||
return this.find(query)
|
||||
.populate('author', 'firstName lastName avatar')
|
||||
.sort({ publishedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
// Static method to increment views
|
||||
blogPostSchema.statics.incrementViews = function(postId) {
|
||||
return this.findByIdAndUpdate(postId, { $inc: { views: 1 } });
|
||||
};
|
||||
|
||||
// Instance method to get related posts
|
||||
blogPostSchema.methods.getRelatedPosts = async function(limit = 3) {
|
||||
return this.model('BlogPost').find({
|
||||
_id: { $ne: this._id },
|
||||
status: 'published',
|
||||
publishedAt: { $lte: new Date() },
|
||||
$or: [
|
||||
{ category: this.category },
|
||||
{ tags: { $in: this.tags } }
|
||||
]
|
||||
})
|
||||
.populate('author', 'firstName lastName avatar')
|
||||
.sort({ publishedAt: -1 })
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('BlogPost', blogPostSchema);
|
||||
|
||||
290
models/Booking.js
Normal file
290
models/Booking.js
Normal file
@@ -0,0 +1,290 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const bookingSchema = new mongoose.Schema({
|
||||
// Booking identification
|
||||
bookingNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
confirmationCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
|
||||
// Guest information
|
||||
guest: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Guest',
|
||||
required: true
|
||||
},
|
||||
|
||||
// Room and dates
|
||||
room: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Room',
|
||||
required: true
|
||||
},
|
||||
checkInDate: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
checkOutDate: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Guest details
|
||||
numberOfGuests: {
|
||||
adults: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1
|
||||
},
|
||||
children: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0
|
||||
}
|
||||
},
|
||||
|
||||
// Pricing
|
||||
roomRate: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
numberOfNights: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
subtotal: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
taxes: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
fees: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
discounts: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
totalAmount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Payment information
|
||||
paymentStatus: {
|
||||
type: String,
|
||||
enum: ['Pending', 'Paid', 'Partially Paid', 'Refunded', 'Failed'],
|
||||
default: 'Pending'
|
||||
},
|
||||
paymentMethod: {
|
||||
type: String,
|
||||
enum: ['Credit Card', 'Debit Card', 'Bank Transfer', 'Cash', 'Online Payment']
|
||||
},
|
||||
stripePaymentIntentId: String,
|
||||
|
||||
// Booking status
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['Pending', 'Confirmed', 'Checked In', 'Checked Out', 'Cancelled', 'No Show'],
|
||||
default: 'Pending'
|
||||
},
|
||||
|
||||
// Special requests and notes
|
||||
specialRequests: {
|
||||
type: String,
|
||||
maxlength: 1000
|
||||
},
|
||||
internalNotes: {
|
||||
type: String,
|
||||
maxlength: 1000
|
||||
},
|
||||
|
||||
// Check-in/out details
|
||||
actualCheckInTime: Date,
|
||||
actualCheckOutTime: Date,
|
||||
earlyCheckIn: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
lateCheckOut: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Booking source
|
||||
bookingSource: {
|
||||
type: String,
|
||||
enum: ['Direct', 'Booking.com', 'Expedia', 'Trip.com', 'Phone', 'Walk-in', 'Travel Agent'],
|
||||
default: 'Direct'
|
||||
},
|
||||
|
||||
// External system IDs
|
||||
operaBookingId: String,
|
||||
externalBookingId: String, // ID from booking platforms
|
||||
|
||||
// Cancellation
|
||||
cancellationReason: String,
|
||||
cancellationDate: Date,
|
||||
cancellationFee: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
refundAmount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// Communication
|
||||
emailConfirmationSent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
smsConfirmationSent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Additional services
|
||||
addOns: [{
|
||||
service: String,
|
||||
description: String,
|
||||
quantity: Number,
|
||||
unitPrice: Number,
|
||||
totalPrice: Number
|
||||
}],
|
||||
|
||||
// Group booking
|
||||
isGroupBooking: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
groupSize: Number,
|
||||
groupLeader: String,
|
||||
|
||||
// Loyalty program
|
||||
loyaltyPointsEarned: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
loyaltyPointsRedeemed: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
bookingSchema.index({ bookingNumber: 1 });
|
||||
bookingSchema.index({ confirmationCode: 1 });
|
||||
bookingSchema.index({ guest: 1 });
|
||||
bookingSchema.index({ room: 1 });
|
||||
bookingSchema.index({ checkInDate: 1, checkOutDate: 1 });
|
||||
bookingSchema.index({ status: 1 });
|
||||
bookingSchema.index({ bookingSource: 1 });
|
||||
bookingSchema.index({ operaBookingId: 1 });
|
||||
bookingSchema.index({ createdAt: -1 });
|
||||
|
||||
// Virtual for booking duration
|
||||
bookingSchema.virtual('duration').get(function() {
|
||||
if (this.checkInDate && this.checkOutDate) {
|
||||
const diffTime = Math.abs(this.checkOutDate - this.checkInDate);
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Pre-save middleware to generate booking number and confirmation code
|
||||
bookingSchema.pre('save', function(next) {
|
||||
if (!this.bookingNumber) {
|
||||
// Generate booking number: OVH + year + random 6 digits
|
||||
const year = new Date().getFullYear();
|
||||
const random = Math.floor(100000 + Math.random() * 900000);
|
||||
this.bookingNumber = `OVH${year}${random}`;
|
||||
}
|
||||
|
||||
if (!this.confirmationCode) {
|
||||
// Generate confirmation code: 8 character alphanumeric
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
this.confirmationCode = code;
|
||||
}
|
||||
|
||||
// Calculate number of nights if not set
|
||||
if (!this.numberOfNights && this.checkInDate && this.checkOutDate) {
|
||||
const diffTime = Math.abs(this.checkOutDate - this.checkInDate);
|
||||
this.numberOfNights = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Static method to generate revenue reports
|
||||
bookingSchema.statics.generateRevenueReport = function(startDate, endDate) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
status: { $in: ['Confirmed', 'Checked In', 'Checked Out'] },
|
||||
checkInDate: { $gte: startDate, $lte: endDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
year: { $year: '$checkInDate' },
|
||||
month: { $month: '$checkInDate' },
|
||||
day: { $dayOfMonth: '$checkInDate' }
|
||||
},
|
||||
totalRevenue: { $sum: '$totalAmount' },
|
||||
bookingsCount: { $sum: 1 },
|
||||
averageRate: { $avg: '$roomRate' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1 }
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
// Instance method to check if booking can be cancelled
|
||||
bookingSchema.methods.canBeCancelled = function() {
|
||||
const now = new Date();
|
||||
const checkInDate = new Date(this.checkInDate);
|
||||
const hoursUntilCheckIn = (checkInDate - now) / (1000 * 60 * 60);
|
||||
|
||||
return (
|
||||
this.status === 'Confirmed' &&
|
||||
hoursUntilCheckIn > 24 // Can cancel up to 24 hours before check-in
|
||||
);
|
||||
};
|
||||
|
||||
// Instance method to calculate cancellation fee
|
||||
bookingSchema.methods.calculateCancellationFee = function() {
|
||||
const now = new Date();
|
||||
const checkInDate = new Date(this.checkInDate);
|
||||
const hoursUntilCheckIn = (checkInDate - now) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursUntilCheckIn > 48) {
|
||||
return 0; // Free cancellation
|
||||
} else if (hoursUntilCheckIn > 24) {
|
||||
return this.totalAmount * 0.25; // 25% fee
|
||||
} else {
|
||||
return this.totalAmount * 0.50; // 50% fee
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Booking', bookingSchema);
|
||||
90
models/Content.js
Normal file
90
models/Content.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const contentSchema = new mongoose.Schema({
|
||||
// Content identification
|
||||
page: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
enum: ['home', 'about', 'contact', 'rooms', 'facilities', 'gallery']
|
||||
},
|
||||
|
||||
// Hero section
|
||||
hero: {
|
||||
title: String,
|
||||
subtitle: String,
|
||||
description: String,
|
||||
backgroundImage: String,
|
||||
ctaText: String,
|
||||
ctaLink: String
|
||||
},
|
||||
|
||||
// Page sections (flexible structure for different pages)
|
||||
sections: [{
|
||||
sectionId: String, // e.g., 'welcome', 'features', 'testimonials'
|
||||
title: String,
|
||||
subtitle: String,
|
||||
content: String,
|
||||
image: String,
|
||||
order: Number,
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// Additional fields for different section types
|
||||
items: [mongoose.Schema.Types.Mixed], // For lists, features, etc.
|
||||
backgroundImage: String,
|
||||
backgroundVideo: String,
|
||||
layout: String // 'left-image', 'right-image', 'full-width', etc.
|
||||
}],
|
||||
|
||||
// Meta information for SEO
|
||||
seo: {
|
||||
title: String,
|
||||
description: String,
|
||||
keywords: [String],
|
||||
ogImage: String,
|
||||
canonicalUrl: String
|
||||
},
|
||||
|
||||
// Version control
|
||||
version: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
isPublished: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
publishedAt: Date,
|
||||
|
||||
// Audit trail
|
||||
lastModifiedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin'
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
contentSchema.index({ page: 1 });
|
||||
contentSchema.index({ isPublished: 1 });
|
||||
|
||||
// Pre-save middleware to update version and publish date
|
||||
contentSchema.pre('save', function(next) {
|
||||
if (this.isModified() && !this.isNew) {
|
||||
this.version += 1;
|
||||
}
|
||||
|
||||
if (this.isModified('isPublished') && this.isPublished) {
|
||||
this.publishedAt = new Date();
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Content', contentSchema);
|
||||
|
||||
93
models/GalleryCategory.js
Normal file
93
models/GalleryCategory.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const galleryCategorySchema = new mongoose.Schema({
|
||||
// Basic category information
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
unique: true
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
shortDescription: {
|
||||
type: String,
|
||||
maxlength: 200
|
||||
},
|
||||
|
||||
// Category images (gallery)
|
||||
images: [{
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isPrimary: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}],
|
||||
|
||||
// Display settings
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
displayOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// SEO and metadata
|
||||
metaTitle: String,
|
||||
metaDescription: String
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
galleryCategorySchema.index({ slug: 1 });
|
||||
galleryCategorySchema.index({ isActive: 1, displayOrder: 1 });
|
||||
|
||||
// Pre-save middleware to generate slug
|
||||
galleryCategorySchema.pre('save', function(next) {
|
||||
if (this.isModified('name') || !this.slug) {
|
||||
this.slug = this.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Virtual for primary image
|
||||
galleryCategorySchema.virtual('primaryImage').get(function() {
|
||||
const primary = this.images.find(img => img.isPrimary);
|
||||
return primary ? primary.url : (this.images.length > 0 ? this.images[0].url : null);
|
||||
});
|
||||
|
||||
// Virtual for image count
|
||||
galleryCategorySchema.virtual('imageCount').get(function() {
|
||||
return this.images.length;
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('GalleryCategory', galleryCategorySchema);
|
||||
|
||||
338
models/Guest.js
Normal file
338
models/Guest.js
Normal file
@@ -0,0 +1,338 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const guestSchema = new mongoose.Schema({
|
||||
// Personal information
|
||||
firstName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
lastName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true,
|
||||
trim: true,
|
||||
match: [/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/, 'Please enter a valid email']
|
||||
},
|
||||
phone: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// Authentication
|
||||
password: {
|
||||
type: String,
|
||||
minlength: 6,
|
||||
select: false // Don't include in queries by default
|
||||
},
|
||||
isRegistered: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Address information
|
||||
address: {
|
||||
street: String,
|
||||
city: String,
|
||||
state: String,
|
||||
country: String,
|
||||
zipCode: String
|
||||
},
|
||||
|
||||
// Personal details
|
||||
dateOfBirth: Date,
|
||||
nationality: String,
|
||||
gender: {
|
||||
type: String,
|
||||
enum: ['Male', 'Female', 'Other', 'Prefer not to say']
|
||||
},
|
||||
|
||||
// Identification
|
||||
idType: {
|
||||
type: String,
|
||||
enum: ['Passport', 'Driver License', 'National ID', 'Other']
|
||||
},
|
||||
idNumber: String,
|
||||
idExpiryDate: Date,
|
||||
|
||||
// Preferences
|
||||
preferences: {
|
||||
roomType: {
|
||||
type: String,
|
||||
enum: ['Standard', 'Deluxe', 'Suite', 'Executive Suite', 'Presidential Suite']
|
||||
},
|
||||
bedPreference: {
|
||||
type: String,
|
||||
enum: ['Single', 'Double', 'Queen', 'King', 'Twin']
|
||||
},
|
||||
smokingPreference: {
|
||||
type: String,
|
||||
enum: ['Non-smoking', 'Smoking'],
|
||||
default: 'Non-smoking'
|
||||
},
|
||||
floorPreference: {
|
||||
type: String,
|
||||
enum: ['Low', 'High', 'No preference'],
|
||||
default: 'No preference'
|
||||
},
|
||||
viewPreference: {
|
||||
type: String,
|
||||
enum: ['Ocean', 'City', 'Garden', 'Mountain', 'No preference'],
|
||||
default: 'No preference'
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'en'
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: 'USD'
|
||||
}
|
||||
},
|
||||
|
||||
// Special requirements
|
||||
specialRequirements: {
|
||||
accessibility: {
|
||||
wheelchairAccess: { type: Boolean, default: false },
|
||||
hearingImpaired: { type: Boolean, default: false },
|
||||
visuallyImpaired: { type: Boolean, default: false },
|
||||
other: String
|
||||
},
|
||||
dietaryRestrictions: [{
|
||||
type: String,
|
||||
enum: ['Vegetarian', 'Vegan', 'Gluten-free', 'Halal', 'Kosher', 'Diabetic', 'Other']
|
||||
}],
|
||||
allergies: [String],
|
||||
medicalConditions: String
|
||||
},
|
||||
|
||||
// Loyalty program
|
||||
loyaltyProgram: {
|
||||
memberId: String,
|
||||
tier: {
|
||||
type: String,
|
||||
enum: ['Bronze', 'Silver', 'Gold', 'Platinum'],
|
||||
default: 'Bronze'
|
||||
},
|
||||
points: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
joinDate: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
},
|
||||
|
||||
// Communication preferences
|
||||
communicationPreferences: {
|
||||
email: {
|
||||
marketing: { type: Boolean, default: false },
|
||||
bookingUpdates: { type: Boolean, default: true },
|
||||
specialOffers: { type: Boolean, default: false }
|
||||
},
|
||||
sms: {
|
||||
marketing: { type: Boolean, default: false },
|
||||
bookingUpdates: { type: Boolean, default: false },
|
||||
specialOffers: { type: Boolean, default: false }
|
||||
},
|
||||
phone: {
|
||||
marketing: { type: Boolean, default: false },
|
||||
bookingUpdates: { type: Boolean, default: false }
|
||||
}
|
||||
},
|
||||
|
||||
// Guest history
|
||||
totalStays: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
totalSpent: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lastStayDate: Date,
|
||||
averageRating: {
|
||||
type: Number,
|
||||
min: 1,
|
||||
max: 5
|
||||
},
|
||||
|
||||
// VIP status
|
||||
isVIP: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
vipNotes: String,
|
||||
|
||||
// External system IDs
|
||||
operaGuestId: String,
|
||||
externalGuestIds: [{
|
||||
system: String, // 'booking.com', 'expedia', etc.
|
||||
id: String
|
||||
}],
|
||||
|
||||
// Emergency contact
|
||||
emergencyContact: {
|
||||
name: String,
|
||||
relationship: String,
|
||||
phone: String,
|
||||
email: String
|
||||
},
|
||||
|
||||
// Account status
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isBlacklisted: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
blacklistReason: String,
|
||||
|
||||
// Verification
|
||||
emailVerified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
phoneVerified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
emailVerificationToken: String,
|
||||
passwordResetToken: String,
|
||||
passwordResetExpires: Date,
|
||||
|
||||
// Notes and comments
|
||||
internalNotes: String,
|
||||
|
||||
// GDPR compliance
|
||||
dataConsent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
consentDate: Date,
|
||||
|
||||
// Last activity
|
||||
lastLogin: Date,
|
||||
lastActivity: Date
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
guestSchema.index({ email: 1 });
|
||||
guestSchema.index({ phone: 1 });
|
||||
guestSchema.index({ 'loyaltyProgram.memberId': 1 });
|
||||
guestSchema.index({ operaGuestId: 1 });
|
||||
guestSchema.index({ isVIP: 1 });
|
||||
guestSchema.index({ totalStays: -1 });
|
||||
guestSchema.index({ totalSpent: -1 });
|
||||
|
||||
// Virtual for full name
|
||||
guestSchema.virtual('fullName').get(function() {
|
||||
return `${this.firstName} ${this.lastName}`;
|
||||
});
|
||||
|
||||
// Virtual for formatted address
|
||||
guestSchema.virtual('formattedAddress').get(function() {
|
||||
if (!this.address || !this.address.street) return '';
|
||||
|
||||
const { street, city, state, country, zipCode } = this.address;
|
||||
return `${street}, ${city}, ${state} ${zipCode}, ${country}`;
|
||||
});
|
||||
|
||||
// Pre-save middleware to hash password
|
||||
guestSchema.pre('save', async function(next) {
|
||||
// Only hash password if it's modified and exists
|
||||
if (!this.isModified('password') || !this.password) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
// Hash password with cost of 12
|
||||
const hashedPassword = await bcrypt.hash(this.password, 12);
|
||||
this.password = hashedPassword;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Method to check password
|
||||
guestSchema.methods.comparePassword = async function(candidatePassword) {
|
||||
if (!this.password) return false;
|
||||
return bcrypt.compare(candidatePassword, this.password);
|
||||
};
|
||||
|
||||
// Method to update loyalty points
|
||||
guestSchema.methods.addLoyaltyPoints = function(points) {
|
||||
this.loyaltyProgram.points += points;
|
||||
|
||||
// Update tier based on points
|
||||
if (this.loyaltyProgram.points >= 10000) {
|
||||
this.loyaltyProgram.tier = 'Platinum';
|
||||
} else if (this.loyaltyProgram.points >= 5000) {
|
||||
this.loyaltyProgram.tier = 'Gold';
|
||||
} else if (this.loyaltyProgram.points >= 1000) {
|
||||
this.loyaltyProgram.tier = 'Silver';
|
||||
}
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Method to update stay statistics
|
||||
guestSchema.methods.updateStayStats = function(stayAmount) {
|
||||
this.totalStays += 1;
|
||||
this.totalSpent += stayAmount;
|
||||
this.lastStayDate = new Date();
|
||||
|
||||
// Check VIP status
|
||||
if (this.totalStays >= 10 && this.totalSpent >= 5000) {
|
||||
this.isVIP = true;
|
||||
}
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Static method to find VIP guests
|
||||
guestSchema.statics.findVIPGuests = function() {
|
||||
return this.find({ isVIP: true, isActive: true })
|
||||
.sort({ totalSpent: -1 })
|
||||
.select('firstName lastName email phone totalStays totalSpent loyaltyProgram');
|
||||
};
|
||||
|
||||
// Static method to find guests by loyalty tier
|
||||
guestSchema.statics.findByLoyaltyTier = function(tier) {
|
||||
return this.find({
|
||||
'loyaltyProgram.tier': tier,
|
||||
isActive: true
|
||||
}).sort({ 'loyaltyProgram.points': -1 });
|
||||
};
|
||||
|
||||
// Method to generate password reset token
|
||||
guestSchema.methods.createPasswordResetToken = function() {
|
||||
const resetToken = require('crypto').randomBytes(32).toString('hex');
|
||||
|
||||
this.passwordResetToken = require('crypto')
|
||||
.createHash('sha256')
|
||||
.update(resetToken)
|
||||
.digest('hex');
|
||||
|
||||
this.passwordResetExpires = Date.now() + 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
return resetToken;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Guest', guestSchema);
|
||||
153
models/Media.js
Normal file
153
models/Media.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const mediaSchema = new mongoose.Schema({
|
||||
// File information
|
||||
filename: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
originalName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
thumbnailUrl: String,
|
||||
|
||||
// File details
|
||||
mimeType: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Media type
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['image', 'video', 'document', 'other'],
|
||||
required: true
|
||||
},
|
||||
|
||||
// Image specific
|
||||
dimensions: {
|
||||
width: Number,
|
||||
height: Number
|
||||
},
|
||||
|
||||
// Categorization
|
||||
folder: {
|
||||
type: String,
|
||||
default: 'general',
|
||||
enum: ['general', 'rooms', 'gallery', 'blog', 'hero', 'about', 'facilities', 'avatars']
|
||||
},
|
||||
tags: [String],
|
||||
|
||||
// Metadata
|
||||
alt: String,
|
||||
caption: String,
|
||||
description: String,
|
||||
|
||||
// Usage tracking
|
||||
usedIn: [{
|
||||
model: String, // 'Content', 'BlogPost', 'Room', etc.
|
||||
documentId: mongoose.Schema.Types.ObjectId,
|
||||
field: String
|
||||
}],
|
||||
|
||||
// Upload information
|
||||
uploadedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin',
|
||||
required: true
|
||||
},
|
||||
|
||||
// Status
|
||||
isPublic: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
mediaSchema.index({ folder: 1, createdAt: -1 });
|
||||
mediaSchema.index({ type: 1 });
|
||||
mediaSchema.index({ uploadedBy: 1 });
|
||||
mediaSchema.index({ tags: 1 });
|
||||
mediaSchema.index({ filename: 1 });
|
||||
|
||||
// Virtual for file extension
|
||||
mediaSchema.virtual('extension').get(function() {
|
||||
return this.filename.split('.').pop().toLowerCase();
|
||||
});
|
||||
|
||||
// Virtual for is image
|
||||
mediaSchema.virtual('isImage').get(function() {
|
||||
return this.type === 'image';
|
||||
});
|
||||
|
||||
// Virtual for file size in MB
|
||||
mediaSchema.virtual('sizeInMB').get(function() {
|
||||
return (this.size / (1024 * 1024)).toFixed(2);
|
||||
});
|
||||
|
||||
// Static method to get media by folder
|
||||
mediaSchema.statics.getByFolder = function(folder, options = {}) {
|
||||
const { limit = 50, skip = 0, type } = options;
|
||||
|
||||
const query = { folder };
|
||||
if (type) query.type = type;
|
||||
|
||||
return this.find(query)
|
||||
.populate('uploadedBy', 'firstName lastName')
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
// Static method to search media
|
||||
mediaSchema.statics.search = function(searchTerm, options = {}) {
|
||||
const { folder, type, limit = 50 } = options;
|
||||
|
||||
const query = {
|
||||
$or: [
|
||||
{ originalName: new RegExp(searchTerm, 'i') },
|
||||
{ alt: new RegExp(searchTerm, 'i') },
|
||||
{ caption: new RegExp(searchTerm, 'i') },
|
||||
{ tags: new RegExp(searchTerm, 'i') }
|
||||
]
|
||||
};
|
||||
|
||||
if (folder) query.folder = folder;
|
||||
if (type) query.type = type;
|
||||
|
||||
return this.find(query)
|
||||
.populate('uploadedBy', 'firstName lastName')
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
// Instance method to track usage
|
||||
mediaSchema.methods.addUsage = function(model, documentId, field) {
|
||||
this.usedIn.push({ model, documentId, field });
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Instance method to remove usage
|
||||
mediaSchema.methods.removeUsage = function(model, documentId, field) {
|
||||
this.usedIn = this.usedIn.filter(usage =>
|
||||
!(usage.model === model && usage.documentId.equals(documentId) && usage.field === field)
|
||||
);
|
||||
return this.save();
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Media', mediaSchema);
|
||||
|
||||
239
models/Room.js
Normal file
239
models/Room.js
Normal file
@@ -0,0 +1,239 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const roomSchema = new mongoose.Schema({
|
||||
// Basic room information
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['Standard', 'Deluxe', 'Suite', 'Executive Suite', 'Presidential Suite'],
|
||||
},
|
||||
category: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'RoomCategory',
|
||||
required: false // Optional for backward compatibility
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
shortDescription: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 200
|
||||
},
|
||||
|
||||
// Room specifications
|
||||
roomNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
floor: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: Number, // in square meters
|
||||
required: true
|
||||
},
|
||||
maxOccupancy: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 8
|
||||
},
|
||||
bedType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['Single', 'Double', 'Queen', 'King', 'Twin', 'Sofa Bed']
|
||||
},
|
||||
bedCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1
|
||||
},
|
||||
|
||||
// Pricing
|
||||
basePrice: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
seasonalPricing: [{
|
||||
season: String,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
priceMultiplier: Number // 1.2 for 20% increase
|
||||
}],
|
||||
|
||||
// Room features and amenities
|
||||
amenities: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'WiFi', 'TV', 'AC', 'Minibar', 'Safe', 'Balcony', 'Ocean View',
|
||||
'City View', 'Mountain View', 'Garden View', 'Jacuzzi', 'Fireplace',
|
||||
'Kitchen', 'Kitchenette', 'Workspace', 'Butler Service', 'Spa Access',
|
||||
'Private Pool', 'Terrace', 'Walk-in Closet', 'Sound System'
|
||||
]
|
||||
}],
|
||||
|
||||
// Media
|
||||
images: [{
|
||||
url: String,
|
||||
alt: String,
|
||||
isPrimary: { type: Boolean, default: false }
|
||||
}],
|
||||
virtualTour: {
|
||||
url: String,
|
||||
provider: String // '360°', 'Matterport', etc.
|
||||
},
|
||||
|
||||
// Availability and status
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['Available', 'Occupied', 'Out of Order', 'Maintenance'],
|
||||
default: 'Available'
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// Integration IDs for external systems
|
||||
operaRoomId: String, // Opera PMS room ID
|
||||
bookingComRoomId: String,
|
||||
expediaRoomId: String,
|
||||
tripComRoomId: String,
|
||||
|
||||
// SEO and metadata
|
||||
slug: {
|
||||
type: String,
|
||||
unique: true,
|
||||
lowercase: true
|
||||
},
|
||||
metaTitle: String,
|
||||
metaDescription: String,
|
||||
|
||||
// Room policies
|
||||
smokingAllowed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
petsAllowed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Maintenance and housekeeping
|
||||
lastMaintenance: Date,
|
||||
lastCleaning: Date,
|
||||
cleaningStatus: {
|
||||
type: String,
|
||||
enum: ['Clean', 'Dirty', 'In Progress', 'Inspected'],
|
||||
default: 'Clean'
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
roomSchema.index({ roomNumber: 1 });
|
||||
roomSchema.index({ type: 1, status: 1 });
|
||||
roomSchema.index({ slug: 1 });
|
||||
roomSchema.index({ category: 1 });
|
||||
roomSchema.index({ operaRoomId: 1 });
|
||||
|
||||
// Virtual for current price (considering seasonal pricing)
|
||||
roomSchema.virtual('currentPrice').get(function() {
|
||||
const now = new Date();
|
||||
const seasonalRate = this.seasonalPricing.find(pricing =>
|
||||
pricing.startDate <= now && pricing.endDate >= now
|
||||
);
|
||||
|
||||
return seasonalRate
|
||||
? this.basePrice * seasonalRate.priceMultiplier
|
||||
: this.basePrice;
|
||||
});
|
||||
|
||||
// Pre-save middleware to generate slug
|
||||
roomSchema.pre('save', function(next) {
|
||||
if (this.isModified('name') || !this.slug) {
|
||||
this.slug = this.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Static method to find available rooms
|
||||
roomSchema.statics.findAvailable = function(checkIn, checkOut, guests = 1) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
status: 'Available',
|
||||
isActive: true,
|
||||
maxOccupancy: { $gte: guests }
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'bookings',
|
||||
let: { roomId: '$_id' },
|
||||
pipeline: [
|
||||
{
|
||||
$match: {
|
||||
$expr: { $eq: ['$room', '$$roomId'] },
|
||||
status: { $in: ['Confirmed', 'Checked In'] },
|
||||
$or: [
|
||||
{
|
||||
checkInDate: { $lt: checkOut },
|
||||
checkOutDate: { $gt: checkIn }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
as: 'conflictingBookings'
|
||||
}
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
conflictingBookings: { $size: 0 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
conflictingBookings: 0
|
||||
}
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
// Instance method to check availability
|
||||
roomSchema.methods.isAvailable = async function(checkIn, checkOut) {
|
||||
const Booking = mongoose.model('Booking');
|
||||
|
||||
const conflictingBooking = await Booking.findOne({
|
||||
room: this._id,
|
||||
status: { $in: ['Confirmed', 'Checked In'] },
|
||||
$or: [
|
||||
{
|
||||
checkInDate: { $lt: checkOut },
|
||||
checkOutDate: { $gt: checkIn }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return !conflictingBooking && this.status === 'Available' && this.isActive;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Room', roomSchema);
|
||||
116
models/RoomCategory.js
Normal file
116
models/RoomCategory.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const roomCategorySchema = new mongoose.Schema({
|
||||
// Basic category information
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
unique: true
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
shortDescription: {
|
||||
type: String,
|
||||
maxlength: 200
|
||||
},
|
||||
|
||||
// Category images (gallery)
|
||||
images: [{
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isPrimary: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}],
|
||||
|
||||
// Pricing information (range for this category)
|
||||
priceRange: {
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
|
||||
// Category features/amenities (common to all rooms in this category)
|
||||
features: [{
|
||||
type: String
|
||||
}],
|
||||
|
||||
// SEO and metadata
|
||||
metaTitle: String,
|
||||
metaDescription: String,
|
||||
|
||||
// Display settings
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
displayOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// Statistics (calculated)
|
||||
roomCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Indexes
|
||||
roomCategorySchema.index({ slug: 1 });
|
||||
roomCategorySchema.index({ isActive: 1, displayOrder: 1 });
|
||||
|
||||
// Pre-save middleware to generate slug
|
||||
roomCategorySchema.pre('save', function(next) {
|
||||
if (this.isModified('name') || !this.slug) {
|
||||
this.slug = this.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Virtual for primary image
|
||||
roomCategorySchema.virtual('primaryImage').get(function() {
|
||||
const primary = this.images.find(img => img.isPrimary);
|
||||
return primary ? primary.url : (this.images.length > 0 ? this.images[0].url : null);
|
||||
});
|
||||
|
||||
// Virtual for image count
|
||||
roomCategorySchema.virtual('imageCount').get(function() {
|
||||
return this.images.length;
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('RoomCategory', roomCategorySchema);
|
||||
|
||||
190
models/SiteSettings.js
Normal file
190
models/SiteSettings.js
Normal file
@@ -0,0 +1,190 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const siteSettingsSchema = new mongoose.Schema({
|
||||
// Hotel information
|
||||
hotel: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'The Old Vine Hotel'
|
||||
},
|
||||
tagline: String,
|
||||
description: String,
|
||||
logo: String,
|
||||
favicon: String,
|
||||
|
||||
// Contact information
|
||||
phone: String,
|
||||
email: String,
|
||||
whatsapp: String,
|
||||
website: String,
|
||||
|
||||
// Address
|
||||
address: {
|
||||
street: String,
|
||||
city: String,
|
||||
state: String,
|
||||
zipCode: String,
|
||||
country: String,
|
||||
formatted: String,
|
||||
coordinates: {
|
||||
lat: Number,
|
||||
lng: Number
|
||||
}
|
||||
},
|
||||
|
||||
// Social media
|
||||
socialMedia: {
|
||||
facebook: String,
|
||||
instagram: String,
|
||||
twitter: String,
|
||||
linkedin: String,
|
||||
youtube: String,
|
||||
tiktok: String
|
||||
},
|
||||
|
||||
// Business hours
|
||||
businessHours: {
|
||||
checkIn: { type: String, default: '14:00' },
|
||||
checkOut: { type: String, default: '11:00' },
|
||||
reception: {
|
||||
weekday: String,
|
||||
weekend: String
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Theme and styling
|
||||
theme: {
|
||||
// Color palette
|
||||
colors: {
|
||||
primary: { type: String, default: '#1F423C' },
|
||||
primaryLight: { type: String, default: '#3A635F' },
|
||||
primaryDark: { type: String, default: '#0F2A26' },
|
||||
secondary: { type: String, default: '#9AD4BD' },
|
||||
secondaryLight: { type: String, default: '#B0E0D0' },
|
||||
secondaryDark: { type: String, default: '#7CBF9E' },
|
||||
tertiary: { type: String, default: '#A8A8A8' },
|
||||
background: { type: String, default: '#F8F6F3' },
|
||||
backgroundAlt: { type: String, default: '#E0E8E6' },
|
||||
text: { type: String, default: '#231F20' },
|
||||
textSecondary: { type: String, default: '#6D6E6E' }
|
||||
},
|
||||
|
||||
// Typography
|
||||
fonts: {
|
||||
heading: { type: String, default: 'Cormorant Garamond' },
|
||||
body: { type: String, default: 'Cairo' },
|
||||
headingWeight: { type: String, default: '600' },
|
||||
bodyWeight: { type: String, default: '400' }
|
||||
},
|
||||
|
||||
// Layout
|
||||
layout: {
|
||||
headerStyle: { type: String, default: 'transparent' },
|
||||
footerStyle: { type: String, default: 'dark' },
|
||||
borderRadius: { type: String, default: '4px' },
|
||||
spacing: { type: String, default: 'comfortable' }
|
||||
}
|
||||
},
|
||||
|
||||
// SEO settings
|
||||
seo: {
|
||||
defaultTitle: String,
|
||||
titleTemplate: String, // e.g., '%s | The Old Vine Hotel'
|
||||
defaultDescription: String,
|
||||
keywords: [String],
|
||||
ogImage: String,
|
||||
twitterHandle: String,
|
||||
googleAnalyticsId: String,
|
||||
googleTagManagerId: String,
|
||||
facebookPixelId: String
|
||||
},
|
||||
|
||||
// Booking settings
|
||||
booking: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
minNights: { type: Number, default: 1 },
|
||||
maxNights: { type: Number, default: 30 },
|
||||
advanceBookingDays: { type: Number, default: 365 },
|
||||
cancellationPolicy: String,
|
||||
depositRequired: { type: Boolean, default: false },
|
||||
depositPercentage: { type: Number, default: 30 },
|
||||
taxRate: { type: Number, default: 10 },
|
||||
currency: { type: String, default: 'USD' },
|
||||
currencySymbol: { type: String, default: '$' }
|
||||
},
|
||||
|
||||
// Email settings
|
||||
email: {
|
||||
fromName: String,
|
||||
fromEmail: String,
|
||||
replyToEmail: String,
|
||||
bookingConfirmationEnabled: { type: Boolean, default: true },
|
||||
bookingReminderEnabled: { type: Boolean, default: true },
|
||||
newsletterEnabled: { type: Boolean, default: true }
|
||||
},
|
||||
|
||||
// Feature flags
|
||||
features: {
|
||||
blog: { type: Boolean, default: true },
|
||||
gallery: { type: Boolean, default: true },
|
||||
testimonials: { type: Boolean, default: true },
|
||||
newsletter: { type: Boolean, default: true },
|
||||
liveChat: { type: Boolean, default: false },
|
||||
multiLanguage: { type: Boolean, default: true },
|
||||
darkMode: { type: Boolean, default: false }
|
||||
},
|
||||
|
||||
// Languages
|
||||
languages: {
|
||||
default: { type: String, default: 'en' },
|
||||
available: [{ type: String, default: ['en', 'ar', 'fr'] }],
|
||||
rtlLanguages: [{ type: String, default: ['ar'] }]
|
||||
},
|
||||
|
||||
// Maintenance mode
|
||||
maintenance: {
|
||||
enabled: { type: Boolean, default: false },
|
||||
message: String,
|
||||
allowedIPs: [String]
|
||||
},
|
||||
|
||||
// Version control
|
||||
version: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
|
||||
// Audit trail
|
||||
lastModifiedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin'
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
// Ensure only one settings document exists
|
||||
siteSettingsSchema.statics.getSiteSettings = async function() {
|
||||
let settings = await this.findOne();
|
||||
|
||||
if (!settings) {
|
||||
settings = await this.create({});
|
||||
}
|
||||
|
||||
return settings;
|
||||
};
|
||||
|
||||
// Pre-save middleware to update version
|
||||
siteSettingsSchema.pre('save', function(next) {
|
||||
if (this.isModified() && !this.isNew) {
|
||||
this.version += 1;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('SiteSettings', siteSettingsSchema);
|
||||
|
||||
Reference in New Issue
Block a user