Compare commits
4 Commits
6417fd6b01
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e5919fb6c | ||
|
|
212c86d29d | ||
|
|
3c3aef5446 | ||
|
|
2909b675a1 |
@@ -12,20 +12,31 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
required: true,
|
required: true,
|
||||||
unique: true
|
unique: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// Guest information
|
// Guest information
|
||||||
guest: {
|
guest: {
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'Guest',
|
ref: 'Guest',
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// Room and dates
|
// Room and dates
|
||||||
room: {
|
room: {
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'Room',
|
ref: 'Room',
|
||||||
required: true
|
required: false
|
||||||
},
|
},
|
||||||
|
|
||||||
|
roomCategoryId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'RoomCategory',
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
requestedRoomType: {
|
||||||
|
type: String,
|
||||||
|
maxlength: 200
|
||||||
|
},
|
||||||
|
|
||||||
checkInDate: {
|
checkInDate: {
|
||||||
type: Date,
|
type: Date,
|
||||||
required: true
|
required: true
|
||||||
@@ -34,7 +45,7 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
type: Date,
|
type: Date,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// Guest details
|
// Guest details
|
||||||
numberOfGuests: {
|
numberOfGuests: {
|
||||||
adults: {
|
adults: {
|
||||||
@@ -48,7 +59,7 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
min: 0
|
min: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Pricing
|
// Pricing
|
||||||
roomRate: {
|
roomRate: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -78,7 +89,7 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
type: Number,
|
type: Number,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// Payment information
|
// Payment information
|
||||||
paymentStatus: {
|
paymentStatus: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -90,14 +101,14 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
enum: ['Credit Card', 'Debit Card', 'Bank Transfer', 'Cash', 'Online Payment']
|
enum: ['Credit Card', 'Debit Card', 'Bank Transfer', 'Cash', 'Online Payment']
|
||||||
},
|
},
|
||||||
stripePaymentIntentId: String,
|
stripePaymentIntentId: String,
|
||||||
|
|
||||||
// Booking status
|
// Booking status
|
||||||
status: {
|
status: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['Pending', 'Confirmed', 'Checked In', 'Checked Out', 'Cancelled', 'No Show'],
|
enum: ['Pending', 'Confirmed', 'Checked In', 'Checked Out', 'Cancelled', 'No Show'],
|
||||||
default: 'Pending'
|
default: 'Pending'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Special requests and notes
|
// Special requests and notes
|
||||||
specialRequests: {
|
specialRequests: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -107,7 +118,7 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
type: String,
|
type: String,
|
||||||
maxlength: 1000
|
maxlength: 1000
|
||||||
},
|
},
|
||||||
|
|
||||||
// Check-in/out details
|
// Check-in/out details
|
||||||
actualCheckInTime: Date,
|
actualCheckInTime: Date,
|
||||||
actualCheckOutTime: Date,
|
actualCheckOutTime: Date,
|
||||||
@@ -119,18 +130,18 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
|
||||||
// Booking source
|
// Booking source
|
||||||
bookingSource: {
|
bookingSource: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['Direct', 'Booking.com', 'Expedia', 'Trip.com', 'Phone', 'Walk-in', 'Travel Agent'],
|
enum: ['Direct', 'Booking.com', 'Expedia', 'Trip.com', 'Phone', 'Walk-in', 'Travel Agent'],
|
||||||
default: 'Direct'
|
default: 'Direct'
|
||||||
},
|
},
|
||||||
|
|
||||||
// External system IDs
|
// External system IDs
|
||||||
operaBookingId: String,
|
operaBookingId: String,
|
||||||
externalBookingId: String, // ID from booking platforms
|
externalBookingId: String, // ID from booking platforms
|
||||||
|
|
||||||
// Cancellation
|
// Cancellation
|
||||||
cancellationReason: String,
|
cancellationReason: String,
|
||||||
cancellationDate: Date,
|
cancellationDate: Date,
|
||||||
@@ -142,7 +153,7 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
|
|
||||||
// Communication
|
// Communication
|
||||||
emailConfirmationSent: {
|
emailConfirmationSent: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -152,7 +163,7 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
|
||||||
// Additional services
|
// Additional services
|
||||||
addOns: [{
|
addOns: [{
|
||||||
service: String,
|
service: String,
|
||||||
@@ -161,7 +172,7 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
unitPrice: Number,
|
unitPrice: Number,
|
||||||
totalPrice: Number
|
totalPrice: Number
|
||||||
}],
|
}],
|
||||||
|
|
||||||
// Group booking
|
// Group booking
|
||||||
isGroupBooking: {
|
isGroupBooking: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -169,7 +180,7 @@ const bookingSchema = new mongoose.Schema({
|
|||||||
},
|
},
|
||||||
groupSize: Number,
|
groupSize: Number,
|
||||||
groupLeader: String,
|
groupLeader: String,
|
||||||
|
|
||||||
// Loyalty program
|
// Loyalty program
|
||||||
loyaltyPointsEarned: {
|
loyaltyPointsEarned: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -213,7 +224,7 @@ bookingSchema.pre('validate', function(next) {
|
|||||||
const random = Math.floor(100000 + Math.random() * 900000);
|
const random = Math.floor(100000 + Math.random() * 900000);
|
||||||
this.bookingNumber = `OVH${year}${random}`;
|
this.bookingNumber = `OVH${year}${random}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.confirmationCode) {
|
if (!this.confirmationCode) {
|
||||||
// Generate confirmation code: 8 character alphanumeric
|
// Generate confirmation code: 8 character alphanumeric
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
@@ -223,13 +234,13 @@ bookingSchema.pre('validate', function(next) {
|
|||||||
}
|
}
|
||||||
this.confirmationCode = code;
|
this.confirmationCode = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate number of nights if not set
|
// Calculate number of nights if not set
|
||||||
if (!this.numberOfNights && this.checkInDate && this.checkOutDate) {
|
if (!this.numberOfNights && this.checkInDate && this.checkOutDate) {
|
||||||
const diffTime = Math.abs(this.checkOutDate - this.checkInDate);
|
const diffTime = Math.abs(this.checkOutDate - this.checkInDate);
|
||||||
this.numberOfNights = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
this.numberOfNights = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,9 +276,9 @@ bookingSchema.methods.canBeCancelled = function() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const checkInDate = new Date(this.checkInDate);
|
const checkInDate = new Date(this.checkInDate);
|
||||||
const hoursUntilCheckIn = (checkInDate - now) / (1000 * 60 * 60);
|
const hoursUntilCheckIn = (checkInDate - now) / (1000 * 60 * 60);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.status === 'Confirmed' &&
|
this.status === 'Confirmed' &&
|
||||||
hoursUntilCheckIn > 24 // Can cancel up to 24 hours before check-in
|
hoursUntilCheckIn > 24 // Can cancel up to 24 hours before check-in
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -277,7 +288,7 @@ bookingSchema.methods.calculateCancellationFee = function() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const checkInDate = new Date(this.checkInDate);
|
const checkInDate = new Date(this.checkInDate);
|
||||||
const hoursUntilCheckIn = (checkInDate - now) / (1000 * 60 * 60);
|
const hoursUntilCheckIn = (checkInDate - now) / (1000 * 60 * 60);
|
||||||
|
|
||||||
if (hoursUntilCheckIn > 48) {
|
if (hoursUntilCheckIn > 48) {
|
||||||
return 0; // Free cancellation
|
return 0; // Free cancellation
|
||||||
} else if (hoursUntilCheckIn > 24) {
|
} else if (hoursUntilCheckIn > 24) {
|
||||||
|
|||||||
@@ -10,6 +10,50 @@ const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
|||||||
const sendEmail = require('../utils/sendEmail');
|
const sendEmail = require('../utils/sendEmail');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const escapeRegex = (s = '') => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
async function resolveRoomFromCategory({ roomCategoryId, requestedRoomType, checkIn, checkOut, totalGuests }) {
|
||||||
|
const or = [];
|
||||||
|
|
||||||
|
if (roomCategoryId) {
|
||||||
|
or.push(
|
||||||
|
{ category: roomCategoryId },
|
||||||
|
{ categoryId: roomCategoryId },
|
||||||
|
{ roomCategory: roomCategoryId },
|
||||||
|
{ roomCategoryId: roomCategoryId },
|
||||||
|
{ typeId: roomCategoryId }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedRoomType) {
|
||||||
|
const rx = new RegExp(`^${escapeRegex(requestedRoomType)}$`, 'i');
|
||||||
|
or.push(
|
||||||
|
{ type: rx },
|
||||||
|
{ roomType: rx },
|
||||||
|
{ categoryName: rx },
|
||||||
|
{ name: rx }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (or.length === 0) return null;
|
||||||
|
|
||||||
|
const candidates = await Room.find({
|
||||||
|
isActive: true,
|
||||||
|
$or: or
|
||||||
|
}).limit(50);
|
||||||
|
|
||||||
|
for (const room of candidates) {
|
||||||
|
try {
|
||||||
|
if (room.maxOccupancy != null && room.maxOccupancy < totalGuests) continue;
|
||||||
|
const ok = await room.isAvailable(checkIn, checkOut);
|
||||||
|
if (ok) return room;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore and try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// @route POST /api/bookings/request
|
// @route POST /api/bookings/request
|
||||||
// @desc Create a booking request (no online payment)
|
// @desc Create a booking request (no online payment)
|
||||||
@@ -19,7 +63,21 @@ router.post('/request', [
|
|||||||
body('guestInfo.lastName').notEmpty().withMessage('Last name is required'),
|
body('guestInfo.lastName').notEmpty().withMessage('Last name is required'),
|
||||||
body('guestInfo.email').isEmail().withMessage('Valid email is required'),
|
body('guestInfo.email').isEmail().withMessage('Valid email is required'),
|
||||||
body('guestInfo.phone').notEmpty().withMessage('Phone number is required'),
|
body('guestInfo.phone').notEmpty().withMessage('Phone number is required'),
|
||||||
body('roomId').isMongoId().withMessage('Valid room ID is required'),
|
|
||||||
|
// accept roomId OR roomCategoryId / requestedRoomType
|
||||||
|
body('roomId').optional().isMongoId().withMessage('Valid room ID is required'),
|
||||||
|
body('roomCategoryId').optional().isMongoId().withMessage('Valid room category ID is required'),
|
||||||
|
body('requestedRoomType').optional().isString().isLength({ max: 200 }),
|
||||||
|
body('roomCategory').optional().isString().isLength({ max: 200 }),
|
||||||
|
|
||||||
|
body().custom((_, { req }) => {
|
||||||
|
if (req.body.roomId) return true;
|
||||||
|
if (req.body.roomCategoryId) return true;
|
||||||
|
if (req.body.requestedRoomType) return true;
|
||||||
|
if (req.body.roomCategory) return true;
|
||||||
|
throw new Error('roomId or roomCategoryId/requestedRoomType is required');
|
||||||
|
}),
|
||||||
|
|
||||||
body('checkInDate').isISO8601().withMessage('Valid check-in date is required'),
|
body('checkInDate').isISO8601().withMessage('Valid check-in date is required'),
|
||||||
body('checkOutDate').isISO8601().withMessage('Valid check-out 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.adults').isInt({ min: 1 }).withMessage('At least 1 adult required'),
|
||||||
@@ -35,7 +93,17 @@ router.post('/request', [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { guestInfo, roomId, checkInDate, checkOutDate, numberOfGuests, specialRequests } = req.body;
|
const {
|
||||||
|
guestInfo,
|
||||||
|
roomId,
|
||||||
|
roomCategoryId,
|
||||||
|
roomCategory,
|
||||||
|
requestedRoomType,
|
||||||
|
checkInDate,
|
||||||
|
checkOutDate,
|
||||||
|
numberOfGuests,
|
||||||
|
specialRequests
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
const checkIn = new Date(checkInDate);
|
const checkIn = new Date(checkInDate);
|
||||||
const checkOut = new Date(checkOutDate);
|
const checkOut = new Date(checkOutDate);
|
||||||
@@ -47,25 +115,48 @@ router.post('/request', [
|
|||||||
return res.status(400).json({ success: false, message: 'Check-in date cannot be in the past' });
|
return res.status(400).json({ success: false, message: 'Check-in date cannot be in the past' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const room = await Room.findById(roomId);
|
|
||||||
if (!room || !room.isActive) {
|
|
||||||
return res.status(404).json({ success: false, message: 'Room not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalGuests = Number(numberOfGuests.adults) + Number(numberOfGuests.children || 0);
|
const totalGuests = Number(numberOfGuests.adults) + Number(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 roomId provided, keep old behavior
|
||||||
if (!isAvailable) {
|
let room = null;
|
||||||
return res.status(400).json({
|
if (roomId) {
|
||||||
success: false,
|
room = await Room.findById(roomId);
|
||||||
message: 'Room is not available for the selected dates'
|
if (!room || !room.isActive) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Room not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No roomId => try to resolve an actual room from category/type
|
||||||
|
const typeName = requestedRoomType || roomCategory || null;
|
||||||
|
room = await resolveRoomFromCategory({
|
||||||
|
roomCategoryId,
|
||||||
|
requestedRoomType: typeName,
|
||||||
|
checkIn,
|
||||||
|
checkOut,
|
||||||
|
totalGuests
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If we found a room, enforce occupancy like before
|
||||||
|
if (room && room.maxOccupancy != null && room.maxOccupancy < totalGuests) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Room can accommodate maximum ${room.maxOccupancy} guests`
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create guest
|
// Find or create guest
|
||||||
@@ -80,14 +171,23 @@ router.post('/request', [
|
|||||||
|
|
||||||
// Pricing
|
// Pricing
|
||||||
const numberOfNights = Math.ceil((checkOut - checkIn) / (1000 * 60 * 60 * 24));
|
const numberOfNights = Math.ceil((checkOut - checkIn) / (1000 * 60 * 60 * 24));
|
||||||
const roomRate = room.currentPrice;
|
|
||||||
|
// If room exists => use its currentPrice (old behavior)
|
||||||
|
// If room doesn't exist => set roomRate to 0 (or you can later adjust manually in admin)
|
||||||
|
const roomRate = room ? Number(room.currentPrice || 0) : 0;
|
||||||
|
|
||||||
const subtotal = roomRate * numberOfNights;
|
const subtotal = roomRate * numberOfNights;
|
||||||
const taxes = subtotal * 0.12;
|
const taxes = subtotal * 0.12;
|
||||||
const totalAmount = subtotal + taxes;
|
const totalAmount = subtotal + taxes;
|
||||||
|
|
||||||
const booking = new Booking({
|
const booking = new Booking({
|
||||||
guest: guest._id,
|
guest: guest._id,
|
||||||
room: roomId,
|
room: room ? room._id : undefined,
|
||||||
|
|
||||||
|
// store category/type for category-only requests
|
||||||
|
roomCategoryId: room ? undefined : (roomCategoryId || undefined),
|
||||||
|
requestedRoomType: room ? undefined : (requestedRoomType || roomCategory || undefined),
|
||||||
|
|
||||||
checkInDate: checkIn,
|
checkInDate: checkIn,
|
||||||
checkOutDate: checkOut,
|
checkOutDate: checkOut,
|
||||||
numberOfGuests,
|
numberOfGuests,
|
||||||
@@ -155,7 +255,7 @@ router.post('/', [
|
|||||||
|
|
||||||
const checkIn = new Date(checkInDate);
|
const checkIn = new Date(checkInDate);
|
||||||
const checkOut = new Date(checkOutDate);
|
const checkOut = new Date(checkOutDate);
|
||||||
|
|
||||||
// Validate dates
|
// Validate dates
|
||||||
if (checkIn >= checkOut) {
|
if (checkIn >= checkOut) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -163,7 +263,7 @@ router.post('/', [
|
|||||||
message: 'Check-out date must be after check-in date'
|
message: 'Check-out date must be after check-in date'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkIn < new Date()) {
|
if (checkIn < new Date()) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -284,7 +384,7 @@ router.post('/', [
|
|||||||
room: room
|
room: room
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
booking.emailConfirmationSent = true;
|
booking.emailConfirmationSent = true;
|
||||||
await booking.save();
|
await booking.save();
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
@@ -294,8 +394,8 @@ router.post('/', [
|
|||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: paymentIntent.status === 'succeeded'
|
message: paymentIntent.status === 'succeeded'
|
||||||
? 'Booking confirmed successfully'
|
? 'Booking confirmed successfully'
|
||||||
: 'Booking created, payment processing',
|
: 'Booking created, payment processing',
|
||||||
data: {
|
data: {
|
||||||
booking,
|
booking,
|
||||||
@@ -324,7 +424,7 @@ router.get('/:bookingNumber', async (req, res) => {
|
|||||||
const { confirmationCode } = req.query;
|
const { confirmationCode } = req.query;
|
||||||
|
|
||||||
let booking;
|
let booking;
|
||||||
|
|
||||||
// If confirmation code provided, allow public access
|
// If confirmation code provided, allow public access
|
||||||
if (confirmationCode) {
|
if (confirmationCode) {
|
||||||
booking = await Booking.findOne({
|
booking = await Booking.findOne({
|
||||||
@@ -332,7 +432,6 @@ router.get('/:bookingNumber', async (req, res) => {
|
|||||||
confirmationCode
|
confirmationCode
|
||||||
}).populate(['guest', 'room']);
|
}).populate(['guest', 'room']);
|
||||||
} else {
|
} else {
|
||||||
// Otherwise require authentication (implement auth middleware check here)
|
|
||||||
booking = await Booking.findOne({ bookingNumber })
|
booking = await Booking.findOne({ bookingNumber })
|
||||||
.populate(['guest', 'room']);
|
.populate(['guest', 'room']);
|
||||||
}
|
}
|
||||||
@@ -486,13 +585,13 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
// Build filter
|
// Build filter
|
||||||
let filter = {};
|
let filter = {};
|
||||||
|
|
||||||
if (status) filter.status = status;
|
if (status) filter.status = status;
|
||||||
if (guestEmail) {
|
if (guestEmail) {
|
||||||
const guest = await Guest.findOne({ email: guestEmail });
|
const guest = await Guest.findOne({ email: guestEmail });
|
||||||
if (guest) filter.guest = guest._id;
|
if (guest) filter.guest = guest._id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkInDate || checkOutDate) {
|
if (checkInDate || checkOutDate) {
|
||||||
filter.checkInDate = {};
|
filter.checkInDate = {};
|
||||||
if (checkInDate) filter.checkInDate.$gte = new Date(checkInDate);
|
if (checkInDate) filter.checkInDate.$gte = new Date(checkInDate);
|
||||||
@@ -501,7 +600,7 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
// Build aggregation pipeline for room type filter
|
// Build aggregation pipeline for room type filter
|
||||||
let aggregationPipeline = [{ $match: filter }];
|
let aggregationPipeline = [{ $match: filter }];
|
||||||
|
|
||||||
if (roomType) {
|
if (roomType) {
|
||||||
aggregationPipeline.push(
|
aggregationPipeline.push(
|
||||||
{
|
{
|
||||||
@@ -523,7 +622,7 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
// Add pagination and sorting
|
// Add pagination and sorting
|
||||||
const sortOptions = {};
|
const sortOptions = {};
|
||||||
sortOptions[sortBy] = sortOrder === 'desc' ? -1 : 1;
|
sortOptions[sortBy] = sortOrder === 'desc' ? -1 : 1;
|
||||||
|
|
||||||
aggregationPipeline.push(
|
aggregationPipeline.push(
|
||||||
{ $sort: sortOptions },
|
{ $sort: sortOptions },
|
||||||
{ $skip: (parseInt(page) - 1) * parseInt(limit) },
|
{ $skip: (parseInt(page) - 1) * parseInt(limit) },
|
||||||
@@ -544,12 +643,9 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
as: 'room'
|
as: 'room'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{ $unwind: '$guest' },
|
||||||
$unwind: '$guest'
|
// ✅ preserve bookings even if room is null
|
||||||
},
|
{ $unwind: { path: '$room', preserveNullAndEmptyArrays: true } }
|
||||||
{
|
|
||||||
$unwind: '$room'
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [bookings, totalCount] = await Promise.all([
|
const [bookings, totalCount] = await Promise.all([
|
||||||
@@ -596,6 +692,14 @@ router.put('/:id/checkin', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// guard if no room assigned
|
||||||
|
if (!booking.room) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Booking has no room assigned'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (booking.status !== 'Confirmed') {
|
if (booking.status !== 'Confirmed') {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -603,12 +707,10 @@ router.put('/:id/checkin', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update booking status
|
|
||||||
booking.status = 'Checked In';
|
booking.status = 'Checked In';
|
||||||
booking.actualCheckInTime = new Date();
|
booking.actualCheckInTime = new Date();
|
||||||
await booking.save();
|
await booking.save();
|
||||||
|
|
||||||
// Update room status
|
|
||||||
const room = await Room.findById(booking.room._id);
|
const room = await Room.findById(booking.room._id);
|
||||||
room.status = 'Occupied';
|
room.status = 'Occupied';
|
||||||
await room.save();
|
await room.save();
|
||||||
@@ -642,6 +744,14 @@ router.put('/:id/checkout', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// guard if no room assigned
|
||||||
|
if (!booking.room) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Booking has no room assigned'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (booking.status !== 'Checked In') {
|
if (booking.status !== 'Checked In') {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -649,12 +759,10 @@ router.put('/:id/checkout', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update booking status
|
|
||||||
booking.status = 'Checked Out';
|
booking.status = 'Checked Out';
|
||||||
booking.actualCheckOutTime = new Date();
|
booking.actualCheckOutTime = new Date();
|
||||||
await booking.save();
|
await booking.save();
|
||||||
|
|
||||||
// Update room status
|
|
||||||
const room = await Room.findById(booking.room._id);
|
const room = await Room.findById(booking.room._id);
|
||||||
room.status = 'Available';
|
room.status = 'Available';
|
||||||
room.cleaningStatus = 'Dirty';
|
room.cleaningStatus = 'Dirty';
|
||||||
@@ -681,13 +789,12 @@ router.put('/:id/checkout', adminAuth, async (req, res) => {
|
|||||||
router.get('/analytics/revenue', adminAuth, async (req, res) => {
|
router.get('/analytics/revenue', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { startDate, endDate, groupBy = 'day' } = req.query;
|
const { startDate, endDate, groupBy = 'day' } = req.query;
|
||||||
|
|
||||||
const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
const end = endDate ? new Date(endDate) : new Date();
|
const end = endDate ? new Date(endDate) : new Date();
|
||||||
|
|
||||||
const revenueData = await Booking.generateRevenueReport(start, end);
|
const revenueData = await Booking.generateRevenueReport(start, end);
|
||||||
|
|
||||||
// Calculate total metrics
|
|
||||||
const totalRevenue = revenueData.reduce((sum, day) => sum + day.totalRevenue, 0);
|
const totalRevenue = revenueData.reduce((sum, day) => sum + day.totalRevenue, 0);
|
||||||
const totalBookings = revenueData.reduce((sum, day) => sum + day.bookingsCount, 0);
|
const totalBookings = revenueData.reduce((sum, day) => sum + day.bookingsCount, 0);
|
||||||
const averageBookingValue = totalBookings > 0 ? totalRevenue / totalBookings : 0;
|
const averageBookingValue = totalBookings > 0 ? totalRevenue / totalBookings : 0;
|
||||||
|
|||||||
269
routes/media.js
269
routes/media.js
@@ -2,24 +2,21 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fsNative = require('fs');
|
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
|
||||||
const Media = require('../models/Media');
|
const Media = require('../models/Media');
|
||||||
const adminAuth = require('../middleware/adminAuth');
|
const adminAuth = require('../middleware/adminAuth');
|
||||||
|
|
||||||
// =======================
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
// Config (LOCAL + PROD)
|
|
||||||
// =======================
|
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR
|
|
||||||
? path.resolve(process.env.UPLOAD_DIR)
|
|
||||||
: path.resolve(__dirname, '..', 'uploads'); // local fallback
|
|
||||||
|
|
||||||
const PUBLIC_BASE = process.env.UPLOAD_PUBLIC_URL || '/uploads';
|
// In dev: oldvine_cms/client/public/images
|
||||||
const MEDIA_ROOT = process.env.MEDIA_SUBDIR || 'images';
|
const devImagesRoot = path.join(__dirname, '../../client/public/images');
|
||||||
|
|
||||||
// Ensure upload directories exist
|
// On server: nginx serves /images from /var/www/oldvine/images (recommended)
|
||||||
const ensureUploadDir = async (dir) => {
|
const imagesRoot =
|
||||||
|
process.env.IMAGES_DIR ||
|
||||||
|
(isProd ? '/var/www/oldvine/images' : devImagesRoot);
|
||||||
|
|
||||||
|
const ensureDir = async (dir) => {
|
||||||
try {
|
try {
|
||||||
await fs.access(dir);
|
await fs.access(dir);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -27,26 +24,23 @@ const ensureUploadDir = async (dir) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeFolder = (folder) => {
|
const safeFolder = (s) =>
|
||||||
let f = String(folder || 'general').trim();
|
String(s || 'general')
|
||||||
// allow a-z0-9 _ - and slashes for nested folders
|
.toLowerCase()
|
||||||
f = f.replace(/[^a-z0-9/_-]/gi, '');
|
.replace(/[^a-z0-9]/g, '-')
|
||||||
// prevent traversal
|
.replace(/-+/g, '-')
|
||||||
while (f.includes('..')) f = f.replace(/\.\./g, '');
|
.replace(/^-|-$/g, '') || 'general';
|
||||||
f = f.replace(/^\/+|\/+$/g, '');
|
|
||||||
return f || 'general';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Configure multer storage
|
// Configure multer storage
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: async (req, file, cb) => {
|
destination: async (req, file, cb) => {
|
||||||
try {
|
try {
|
||||||
const folderSafe = sanitizeFolder(req.body.folder);
|
const folder = safeFolder(req.body.folder || 'general');
|
||||||
const uploadPath = path.join(UPLOAD_DIR, MEDIA_ROOT, folderSafe);
|
const uploadPath = path.join(imagesRoot, folder);
|
||||||
await ensureUploadDir(uploadPath);
|
await ensureDir(uploadPath);
|
||||||
cb(null, uploadPath);
|
cb(null, uploadPath);
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
cb(e);
|
cb(err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
@@ -58,13 +52,12 @@ const storage = multer.diskStorage({
|
|||||||
.replace(/[^a-z0-9]/g, '-')
|
.replace(/[^a-z0-9]/g, '-')
|
||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-')
|
||||||
.replace(/^-|-$/g, '');
|
.replace(/^-|-$/g, '');
|
||||||
cb(null, (basename || 'file') + '-' + uniqueSuffix + ext.toLowerCase());
|
cb(null, `${basename || 'media'}-${uniqueSuffix}${ext.toLowerCase()}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// File filter
|
|
||||||
const fileFilter = (req, file, cb) => {
|
const fileFilter = (req, file, cb) => {
|
||||||
const allowedMimeTypes = [
|
const allowed = [
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/jpg',
|
'image/jpg',
|
||||||
'image/png',
|
'image/png',
|
||||||
@@ -75,58 +68,37 @@ const fileFilter = (req, file, cb) => {
|
|||||||
'video/quicktime',
|
'video/quicktime',
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
];
|
];
|
||||||
|
if (allowed.includes(file.mimetype)) return cb(null, true);
|
||||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
cb(new Error('Invalid file type. Only images, videos, and PDFs are allowed.'), false);
|
||||||
cb(null, true);
|
|
||||||
} else {
|
|
||||||
cb(new Error('Invalid file type. Only images, videos, and PDFs are allowed.'), false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
fileFilter,
|
fileFilter,
|
||||||
limits: {
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB limit
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route POST /api/media/upload
|
// POST /api/media/upload
|
||||||
// @desc Upload media file
|
|
||||||
// @access Private (Admin)
|
|
||||||
router.post('/upload', adminAuth, upload.single('file'), async (req, res) => {
|
router.post('/upload', adminAuth, upload.single('file'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) return res.status(400).json({ success: false, message: 'No file uploaded' });
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'No file uploaded',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderSafe = sanitizeFolder(req.body.folder);
|
const folder = safeFolder(req.body.folder || 'general');
|
||||||
const { alt, caption, description, tags } = req.body;
|
const { alt, caption, description, tags } = req.body;
|
||||||
|
|
||||||
// Determine media type
|
|
||||||
let mediaType = 'other';
|
let mediaType = 'other';
|
||||||
if (req.file.mimetype.startsWith('image/')) {
|
if (req.file.mimetype.startsWith('image/')) mediaType = 'image';
|
||||||
mediaType = 'image';
|
else if (req.file.mimetype.startsWith('video/')) mediaType = 'video';
|
||||||
} else if (req.file.mimetype.startsWith('video/')) {
|
else if (req.file.mimetype === 'application/pdf') mediaType = 'document';
|
||||||
mediaType = 'video';
|
|
||||||
} else if (req.file.mimetype === 'application/pdf') {
|
|
||||||
mediaType = 'document';
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${PUBLIC_BASE}/${MEDIA_ROOT}/${folderSafe}/${req.file.filename}`;
|
|
||||||
|
|
||||||
// Create media record
|
|
||||||
const media = new Media({
|
const media = new Media({
|
||||||
filename: req.file.filename,
|
filename: req.file.filename,
|
||||||
originalName: req.file.originalname,
|
originalName: req.file.originalname,
|
||||||
url,
|
url: `/images/${folder}/${req.file.filename}`,
|
||||||
mimeType: req.file.mimetype,
|
mimeType: req.file.mimetype,
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
type: mediaType,
|
type: mediaType,
|
||||||
folder: folderSafe,
|
folder,
|
||||||
alt,
|
alt,
|
||||||
caption,
|
caption,
|
||||||
description,
|
description,
|
||||||
@@ -136,55 +108,37 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => {
|
|||||||
|
|
||||||
await media.save();
|
await media.save();
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({ success: true, message: 'File uploaded successfully', data: { media } });
|
||||||
success: true,
|
|
||||||
message: 'File uploaded successfully',
|
|
||||||
data: { media },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, message: error.message || 'Error uploading file' });
|
||||||
success: false,
|
|
||||||
message: error.message || 'Error uploading file',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route POST /api/media/upload-multiple
|
// 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) => {
|
router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.files || req.files.length === 0) {
|
if (!req.files || req.files.length === 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: 'No files uploaded' });
|
||||||
success: false,
|
|
||||||
message: 'No files uploaded',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderSafe = sanitizeFolder(req.body.folder);
|
const folder = safeFolder(req.body.folder || 'general');
|
||||||
const mediaRecords = [];
|
const mediaRecords = [];
|
||||||
|
|
||||||
for (const file of req.files) {
|
for (const file of req.files) {
|
||||||
let mediaType = 'other';
|
let mediaType = 'other';
|
||||||
if (file.mimetype.startsWith('image/')) {
|
if (file.mimetype.startsWith('image/')) mediaType = 'image';
|
||||||
mediaType = 'image';
|
else if (file.mimetype.startsWith('video/')) mediaType = 'video';
|
||||||
} else if (file.mimetype.startsWith('video/')) {
|
else if (file.mimetype === 'application/pdf') mediaType = 'document';
|
||||||
mediaType = 'video';
|
|
||||||
} else if (file.mimetype === 'application/pdf') {
|
|
||||||
mediaType = 'document';
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${PUBLIC_BASE}/${MEDIA_ROOT}/${folderSafe}/${file.filename}`;
|
|
||||||
|
|
||||||
const media = new Media({
|
const media = new Media({
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
url,
|
url: `/images/${folder}/${file.filename}`,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: mediaType,
|
type: mediaType,
|
||||||
folder: folderSafe,
|
folder,
|
||||||
uploadedBy: req.admin.id,
|
uploadedBy: req.admin.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,16 +153,11 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Multiple upload error:', error);
|
console.error('Multiple upload error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, message: error.message || 'Error uploading files' });
|
||||||
success: false,
|
|
||||||
message: error.message || 'Error uploading files',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route GET /api/media
|
// GET /api/media
|
||||||
// @desc Get all media
|
|
||||||
// @access Private (Admin)
|
|
||||||
router.get('/', adminAuth, async (req, res) => {
|
router.get('/', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { folder, type, page = 1, limit = 50 } = req.query;
|
const { folder, type, page = 1, limit = 50 } = req.query;
|
||||||
@@ -228,57 +177,29 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { media, pagination: { page: parseInt(page, 10), limit: parseInt(limit, 10), total, pages: Math.ceil(total / limit) } },
|
||||||
media,
|
|
||||||
pagination: {
|
|
||||||
page: parseInt(page, 10),
|
|
||||||
limit: parseInt(limit, 10),
|
|
||||||
total,
|
|
||||||
pages: Math.ceil(total / limit),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get media error:', error);
|
console.error('Get media error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, message: 'Error fetching media' });
|
||||||
success: false,
|
|
||||||
message: 'Error fetching media',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route GET /api/media/search
|
// GET /api/media/search
|
||||||
// @desc Search media
|
|
||||||
// @access Private (Admin)
|
|
||||||
router.get('/search', adminAuth, async (req, res) => {
|
router.get('/search', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { q, folder, type, limit = 50 } = req.query;
|
const { q, folder, type, limit = 50 } = req.query;
|
||||||
|
if (!q) return res.status(400).json({ success: false, message: 'Search query is required' });
|
||||||
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, 10) });
|
const media = await Media.search(q, { folder, type, limit: parseInt(limit, 10) });
|
||||||
|
res.json({ success: true, data: { media } });
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: { media },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search media error:', error);
|
console.error('Search media error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, message: 'Error searching media' });
|
||||||
success: false,
|
|
||||||
message: 'Error searching media',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route PUT /api/media/:id
|
// PUT /api/media/:id
|
||||||
// @desc Update media metadata
|
|
||||||
// @access Private (Admin)
|
|
||||||
router.put('/:id', adminAuth, async (req, res) => {
|
router.put('/:id', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -289,107 +210,51 @@ router.put('/:id', adminAuth, async (req, res) => {
|
|||||||
if (caption !== undefined) updateFields.caption = caption;
|
if (caption !== undefined) updateFields.caption = caption;
|
||||||
if (description !== undefined) updateFields.description = description;
|
if (description !== undefined) updateFields.description = description;
|
||||||
if (tags !== undefined) updateFields.tags = tags;
|
if (tags !== undefined) updateFields.tags = tags;
|
||||||
if (folder !== undefined) updateFields.folder = sanitizeFolder(folder);
|
if (folder !== undefined) updateFields.folder = safeFolder(folder);
|
||||||
|
|
||||||
const media = await Media.findByIdAndUpdate(id, { $set: updateFields }, { new: true, runValidators: true });
|
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' });
|
||||||
|
|
||||||
if (!media) {
|
res.json({ success: true, message: 'Media updated successfully', data: { media } });
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Media not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Media updated successfully',
|
|
||||||
data: { media },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update media error:', error);
|
console.error('Update media error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, message: 'Error updating media' });
|
||||||
success: false,
|
|
||||||
message: 'Error updating media',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const safeResolveInside = (base, rel) => {
|
// DELETE /api/media/:id
|
||||||
const baseAbs = path.resolve(base);
|
|
||||||
const targetAbs = path.resolve(baseAbs, rel);
|
|
||||||
if (!targetAbs.startsWith(baseAbs + path.sep)) return null;
|
|
||||||
return targetAbs;
|
|
||||||
};
|
|
||||||
|
|
||||||
// @route DELETE /api/media/:id
|
|
||||||
// @desc Delete media file
|
|
||||||
// @access Private (Admin)
|
|
||||||
router.delete('/:id', adminAuth, async (req, res) => {
|
router.delete('/:id', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
const media = await Media.findById(id);
|
const media = await Media.findById(id);
|
||||||
|
if (!media) return res.status(404).json({ success: false, message: 'Media not found' });
|
||||||
|
|
||||||
if (!media) {
|
// media.url example: /images/general/file.jpg
|
||||||
return res.status(404).json({
|
const rel = media.url.replace(/^\/images\//, '');
|
||||||
success: false,
|
const filePath = path.join(imagesRoot, rel);
|
||||||
message: 'Media not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete file from filesystem (only if inside UPLOAD_DIR)
|
|
||||||
try {
|
try {
|
||||||
const url = String(media.url || '');
|
await fs.unlink(filePath);
|
||||||
let rel = null;
|
|
||||||
|
|
||||||
if (url.startsWith(PUBLIC_BASE + '/')) {
|
|
||||||
rel = url.slice((PUBLIC_BASE + '/').length);
|
|
||||||
} else if (url.startsWith('/uploads/')) {
|
|
||||||
rel = url.slice('/uploads/'.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rel) {
|
|
||||||
const filePath = safeResolveInside(UPLOAD_DIR, rel);
|
|
||||||
if (filePath && fsNative.existsSync(filePath)) {
|
|
||||||
await fs.unlink(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting file:', err);
|
console.error('Error deleting file:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Media.findByIdAndDelete(id);
|
await Media.findByIdAndDelete(id);
|
||||||
|
res.json({ success: true, message: 'Media deleted successfully' });
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Media deleted successfully',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete media error:', error);
|
console.error('Delete media error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, message: 'Error deleting media' });
|
||||||
success: false,
|
|
||||||
message: 'Error deleting media',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route GET /api/media/folders/list
|
// GET /api/media/folders/list
|
||||||
// @desc Get list of all folders
|
|
||||||
// @access Private (Admin)
|
|
||||||
router.get('/folders/list', adminAuth, async (req, res) => {
|
router.get('/folders/list', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const folders = await Media.distinct('folder');
|
const folders = await Media.distinct('folder');
|
||||||
|
res.json({ success: true, data: { folders } });
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: { folders },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get folders error:', error);
|
console.error('Get folders error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, message: 'Error fetching folders' });
|
||||||
success: false,
|
|
||||||
message: 'Error fetching folders',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
186
routes/upload.js
186
routes/upload.js
@@ -6,115 +6,101 @@ const fs = require('fs');
|
|||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const adminAuth = require('../middleware/adminAuth');
|
const adminAuth = require('../middleware/adminAuth');
|
||||||
|
|
||||||
// =======================
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
// Config (LOCAL + PROD)
|
|
||||||
// =======================
|
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR
|
|
||||||
? path.resolve(process.env.UPLOAD_DIR)
|
|
||||||
: path.resolve(__dirname, '..', 'uploads'); // local fallback
|
|
||||||
|
|
||||||
const PUBLIC_BASE = process.env.UPLOAD_PUBLIC_URL || '/uploads';
|
// In dev: oldvine_cms/client/public
|
||||||
|
const devPublicRoot = path.join(__dirname, '../../client/public');
|
||||||
|
|
||||||
|
// On server: nginx serves /uploads from /var/www/oldvine/uploads (matches current nginx config)
|
||||||
|
const uploadsDir =
|
||||||
|
process.env.UPLOADS_DIR ||
|
||||||
|
(isProd ? '/var/www/oldvine/uploads' : path.join(devPublicRoot, 'uploads'));
|
||||||
|
|
||||||
// Ensure uploads directory exists
|
// Ensure uploads directory exists
|
||||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeBaseName = (name) =>
|
||||||
|
String(name || 'file')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\.[^/.]+$/, '') // remove extension
|
||||||
|
.replace(/[^a-z0-9]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
|
||||||
|
const pickOutput = (mimetype) => {
|
||||||
|
if (mimetype === 'image/png') return { ext: '.png', fmt: 'png' };
|
||||||
|
if (mimetype === 'image/webp') return { ext: '.webp', fmt: 'webp' };
|
||||||
|
// default jpeg for jpg/jpeg/gif/others
|
||||||
|
return { ext: '.jpg', fmt: 'jpeg' };
|
||||||
|
};
|
||||||
|
|
||||||
// Configure multer for file upload
|
// Configure multer for file upload
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => cb(null, uploadsDir),
|
||||||
cb(null, UPLOAD_DIR);
|
|
||||||
},
|
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
|
const base = safeBaseName(file.originalname);
|
||||||
const name = path
|
const { ext } = pickOutput(file.mimetype);
|
||||||
.basename(file.originalname, ext)
|
cb(null, `${base || 'upload'}-${uniqueSuffix}${ext}`);
|
||||||
.replace(/[^a-z0-9]/gi, '-')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.replace(/(^-|-$)/g, '');
|
|
||||||
|
|
||||||
cb(null, `${name || 'image'}-${uniqueSuffix}${ext}`);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileFilter = (req, file, cb) => {
|
const fileFilter = (req, file, cb) => {
|
||||||
// Accept images only
|
if (file.mimetype && file.mimetype.startsWith('image/')) return cb(null, true);
|
||||||
if (file.mimetype && file.mimetype.startsWith('image/')) {
|
cb(new Error('Only image files are allowed'), false);
|
||||||
cb(null, true);
|
|
||||||
} else {
|
|
||||||
cb(new Error('Only image files are allowed'), false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
fileFilter,
|
fileFilter,
|
||||||
limits: {
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB limit
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const optimizeImageInPlace = async (filePath, ext) => {
|
|
||||||
const lowerExt = (ext || '').toLowerCase();
|
|
||||||
|
|
||||||
// GIF: skip optimization (sharp may drop animation)
|
|
||||||
if (lowerExt === '.gif') return;
|
|
||||||
|
|
||||||
const tmpPath = filePath + '.tmp';
|
|
||||||
|
|
||||||
const pipeline = sharp(filePath)
|
|
||||||
.rotate()
|
|
||||||
.resize(1920, 1080, {
|
|
||||||
fit: 'inside',
|
|
||||||
withoutEnlargement: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (lowerExt === '.png') {
|
|
||||||
await pipeline.png({ compressionLevel: 9 }).toFile(tmpPath);
|
|
||||||
} else if (lowerExt === '.webp') {
|
|
||||||
await pipeline.webp({ quality: 85 }).toFile(tmpPath);
|
|
||||||
} else {
|
|
||||||
// jpg/jpeg + anything else
|
|
||||||
await pipeline.jpeg({ quality: 85 }).toFile(tmpPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.renameSync(tmpPath, filePath);
|
|
||||||
};
|
|
||||||
|
|
||||||
// @route POST /api/upload
|
// @route POST /api/upload
|
||||||
// @desc Upload single or multiple images
|
// @desc Upload single or multiple images
|
||||||
// @access Private (Admin)
|
// @access Private (Admin)
|
||||||
router.post('/', adminAuth, upload.array('images', 10), async (req, res) => {
|
router.post('/', adminAuth, upload.array('images', 10), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.files || req.files.length === 0) {
|
if (!req.files || req.files.length === 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: 'No files uploaded' });
|
||||||
success: false,
|
|
||||||
message: 'No files uploaded',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadedFiles = await Promise.all(
|
const uploadedFiles = await Promise.all(
|
||||||
req.files.map(async (file) => {
|
req.files.map(async (file) => {
|
||||||
try {
|
try {
|
||||||
const ext = path.extname(file.filename);
|
const tmpPath = `${file.path}.opt`;
|
||||||
await optimizeImageInPlace(file.path, ext);
|
const { fmt } = pickOutput(file.mimetype);
|
||||||
|
|
||||||
|
let img = sharp(file.path).resize(1920, 1080, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fmt === 'png') img = img.png({ compressionLevel: 9 });
|
||||||
|
else if (fmt === 'webp') img = img.webp({ quality: 85 });
|
||||||
|
else img = img.jpeg({ quality: 85 });
|
||||||
|
|
||||||
|
await img.toFile(tmpPath);
|
||||||
|
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
fs.renameSync(tmpPath, file.path);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
url: `${PUBLIC_BASE}/${file.filename}`,
|
url: `/uploads/${file.filename}`,
|
||||||
size: fs.statSync(file.path).size,
|
size: fs.statSync(file.path).size,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
uploadedAt: new Date(),
|
uploadedAt: new Date(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Error processing image:', error);
|
console.error('Error processing image:', err);
|
||||||
return {
|
return {
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
url: `${PUBLIC_BASE}/${file.filename}`,
|
url: `/uploads/${file.filename}`,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
uploadedAt: new Date(),
|
uploadedAt: new Date(),
|
||||||
@@ -124,91 +110,51 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({ success: true, message: 'Files uploaded successfully', data: { files: uploadedFiles } });
|
||||||
success: true,
|
|
||||||
message: 'Files uploaded successfully',
|
|
||||||
data: { files: uploadedFiles },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, message: error.message || 'Error uploading files' });
|
||||||
success: false,
|
|
||||||
message: error.message || 'Error uploading files',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route GET /api/upload/list
|
// @route GET /api/upload/list
|
||||||
// @desc Get list of uploaded files
|
|
||||||
// @access Private (Admin)
|
|
||||||
router.get('/list', adminAuth, async (req, res) => {
|
router.get('/list', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(UPLOAD_DIR);
|
const files = fs.readdirSync(uploadsDir);
|
||||||
|
|
||||||
const fileList = files
|
const fileList = files
|
||||||
.filter((file) => !file.startsWith('.'))
|
.filter((f) => !f.startsWith('.'))
|
||||||
.map((filename) => {
|
.map((filename) => {
|
||||||
const filePath = path.join(UPLOAD_DIR, filename);
|
const filePath = path.join(uploadsDir, filename);
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
|
return { filename, url: `/uploads/${filename}`, size: stats.size, uploadedAt: stats.mtime };
|
||||||
return {
|
|
||||||
filename,
|
|
||||||
url: `${PUBLIC_BASE}/${filename}`,
|
|
||||||
size: stats.size,
|
|
||||||
uploadedAt: stats.mtime,
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.uploadedAt - a.uploadedAt);
|
.sort((a, b) => b.uploadedAt - a.uploadedAt);
|
||||||
|
|
||||||
res.json({
|
res.json({ success: true, data: { files: fileList, total: fileList.length } });
|
||||||
success: true,
|
|
||||||
data: { files: fileList, total: fileList.length },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('List files error:', error);
|
console.error('List files error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, message: 'Error listing files' });
|
||||||
success: false,
|
|
||||||
message: 'Error listing files',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route DELETE /api/upload/:filename
|
// @route DELETE /api/upload/:filename
|
||||||
// @desc Delete an uploaded file
|
|
||||||
// @access Private (Admin)
|
|
||||||
router.delete('/:filename', adminAuth, async (req, res) => {
|
router.delete('/:filename', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { filename } = req.params;
|
const { filename } = req.params;
|
||||||
|
|
||||||
// Security check
|
|
||||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: 'Invalid filename' });
|
||||||
success: false,
|
|
||||||
message: 'Invalid filename',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(UPLOAD_DIR, filename);
|
const filePath = path.join(uploadsDir, filename);
|
||||||
|
if (!fs.existsSync(filePath)) return res.status(404).json({ success: false, message: 'File not found' });
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: 'File not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
|
res.json({ success: true, message: 'File deleted successfully' });
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'File deleted successfully',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete file error:', error);
|
console.error('Delete file error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, message: 'Error deleting file' });
|
||||||
success: false,
|
|
||||||
message: 'Error deleting file',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user