Hotel Booking

This commit is contained in:
Iliyan Angelov
2025-11-16 14:19:13 +02:00
commit 824eec6190
203 changed files with 37696 additions and 0 deletions

35
server/.env.example Normal file
View File

@@ -0,0 +1,35 @@
# Environment
NODE_ENV=development
# Server
PORT=3000
HOST=localhost
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASS=
DB_NAME=hotel_booking_dev
# JWT
JWT_SECRET=your_super_secret_jwt_key_change_this_in_production
JWT_EXPIRES_IN=1h
JWT_REFRESH_SECRET=your_super_secret_refresh_key_change_this_in_production
JWT_REFRESH_EXPIRES_IN=7d
# Client URL
CLIENT_URL=http://localhost:5173
# Upload
MAX_FILE_SIZE=5242880
ALLOWED_FILE_TYPES=image/jpeg,image/png,image/jpg,image/webp
# Pagination
DEFAULT_PAGE_SIZE=10
MAX_PAGE_SIZE=100
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100

42
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# Dependencies
node_modules/
package-lock.json
yarn.lock
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Uploads
uploads/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
.nyc_output/
# Build
dist/
build/
# Temporary files
tmp/
temp/

8
server/.sequelizerc Normal file
View File

@@ -0,0 +1,8 @@
const path = require('path');
module.exports = {
'config': path.resolve('src', 'config', 'database.js'),
'models-path': path.resolve('src/databases', 'models'),
'seeders-path': path.resolve('src/databases', 'seeders'),
'migrations-path': path.resolve('src/databases', 'migrations')
};

165
server/QUICK_START.md Normal file
View File

@@ -0,0 +1,165 @@
# 🚀 QUICK START - Server Setup
## Bước 1: Copy file .env
```bash
cd d:/hotel-booking/server
cp .env.example .env
```
> File .env đã được tạo sẵn với cấu hình mặc định
## Bước 2: Tạo Database (nếu chưa có)
```bash
# Mở MySQL command line
mysql -u root -p
# Tạo database
CREATE DATABASE hotel_db;
# Thoát
exit;
```
## Bước 3: Chạy Migrations
```bash
cd d:/hotel-booking/server
npm run migrate
```
Lệnh này sẽ tạo các bảng:
- roles
- users
- refresh_tokens
- rooms
- room_types
- bookings
- payments
- services
- service_usages
- promotions
- checkin_checkout
- banners
- password_reset_tokens
- reviews
## Bước 4: (Optional) Seed Data
```bash
npm run seed
```
Lệnh này sẽ tạo:
- 3 roles: admin, staff, customer
- Demo users
- Demo rooms & room types
- Demo bookings
## Bước 5: Start Server
```bash
npm run dev
```
Bạn sẽ thấy:
```
✅ Database connection established successfully
📊 Database models synced
🚀 Server running on port 3000
🌐 Environment: development
🔗 API: http://localhost:3000/api
🏥 Health: http://localhost:3000/health
```
## Bước 6: Test API
### Health Check
Mở browser: http://localhost:3000/health
### Test Login (sau khi seed data)
```bash
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@hotel.com","password":"Admin123"}'
```
## ⚠️ Troubleshooting
### Lỗi: "Access denied for user 'root'"
**Giải pháp:** Sửa DB_PASS trong file `.env`
```bash
DB_PASS=your_mysql_password
```
### Lỗi: "Unknown database 'hotel_db'"
**Giải pháp:** Tạo database thủ công (Bước 2)
### Lỗi: "Port 3000 already in use"
**Giải pháp:** Đổi PORT trong `.env`
```bash
PORT=3001
```
### Lỗi: "Cannot find module"
**Giải pháp:** Cài lại dependencies
```bash
npm install
```
## 📝 Next Steps
1. ✅ Server đang chạy
2. ✅ Database đã setup
3. ✅ API endpoints sẵn sàng
4. 🔜 Test với frontend login form
5. 🔜 Implement các API còn lại
## 🧪 Test với Postman
**Collection:** Hotel Booking API
### 1. Register
```
POST http://localhost:3000/api/auth/register
Body (JSON):
{
"name": "Test User",
"email": "test@example.com",
"password": "Test1234",
"phone": "0123456789"
}
```
### 2. Login
```
POST http://localhost:3000/api/auth/login
Body (JSON):
{
"email": "test@example.com",
"password": "Test1234",
"rememberMe": true
}
```
### 3. Get Profile
```
GET http://localhost:3000/api/auth/profile
Headers:
Authorization: Bearer YOUR_ACCESS_TOKEN
```
## ✅ Checklist
- [ ] MySQL đang chạy
- [ ] File .env đã tạo và cấu hình đúng
- [ ] Database hotel_db đã tạo
- [ ] Migrations đã chạy thành công
- [ ] Server đang chạy (port 3000)
- [ ] Health check trả về 200 OK
- [ ] Frontend .env đã có VITE_API_URL=http://localhost:3000
- [ ] Frontend đang chạy (port 5173)
## 🎯 Ready to Test Login!
1. Server: http://localhost:3000 ✅
2. Client: http://localhost:5173 ✅
3. Login page: http://localhost:5173/login ✅
4. API endpoint: http://localhost:3000/api/auth/login ✅
**Tất cả sẵn sàng!** Giờ có thể test login form từ frontend! 🚀

166
server/README.md Normal file
View File

@@ -0,0 +1,166 @@
# Hotel Booking Server - Setup Guide
## 🚀 Quick Start
### 1. Install Dependencies
```bash
cd server
npm install
```
### 2. Configure Environment
Copy `.env.example` to `.env` and update values:
```bash
cp .env.example .env
```
Edit `.env`:
```bash
DB_NAME=hotel_db
DB_USER=root
DB_PASS=your_password
JWT_SECRET=your-secret-key
```
### 3. Setup Database
**Option A: Using existing MySQL database**
```bash
# Create database
mysql -u root -p
CREATE DATABASE hotel_db;
exit;
# Run migrations
npm run migrate
# (Optional) Seed data
npm run seed
```
**Option B: Database will be created automatically**
- Just run the server
- Make sure MySQL is running
- Database will be created on first connection
### 4. Start Server
```bash
# Development mode with nodemon
npm run dev
# Production mode
npm start
```
Server will be available at: `http://localhost:3000`
## 📡 API Endpoints
### Health Check
```bash
GET http://localhost:3000/health
```
### Authentication
```bash
POST /api/auth/register
POST /api/auth/login
POST /api/auth/refresh-token
POST /api/auth/logout
GET /api/auth/profile (Protected)
```
## 🧪 Test API
### Register New User
```bash
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john@example.com",
"password": "Password123",
"phone": "0123456789"
}'
```
### Login
```bash
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "Password123"
}'
```
## ✅ Server Features
- ✅ Express.js setup with security middleware
- ✅ JWT authentication (access + refresh tokens)
- ✅ Password hashing with bcrypt
- ✅ Input validation with express-validator
- ✅ Error handling middleware
- ✅ Rate limiting
- ✅ CORS configuration
- ✅ Request logging with Morgan
- ✅ Compression middleware
- ✅ Helmet security headers
## 📁 Project Structure
```
server/
├── src/
│ ├── config/
│ │ └── database.js # Database configuration
│ ├── controllers/
│ │ └── authController.js # Auth logic
│ ├── databases/
│ │ ├── migrations/ # Database migrations
│ │ ├── models/ # Sequelize models
│ │ └── seeders/ # Seed data
│ ├── middlewares/
│ │ ├── auth.js # JWT verification
│ │ ├── errorHandler.js # Global error handler
│ │ └── validate.js # Validation middleware
│ ├── routes/
│ │ ├── authRoutes.js # Auth routes
│ │ ├── userRoutes.js # User routes
│ │ ├── roomRoutes.js # Room routes
│ │ └── bookingRoutes.js # Booking routes
│ ├── validators/
│ │ └── authValidator.js # Auth validation rules
│ ├── app.js # Express app setup
│ └── server.js # Server entry point
├── .env # Environment variables
├── .env.example # Environment template
└── package.json
```
## 🔧 Troubleshooting
### Database Connection Error
```
Error: Access denied for user 'root'@'localhost'
```
**Solution:** Check DB_USER and DB_PASS in .env
### Port Already in Use
```
Error: listen EADDRINUSE: address already in use :::3000
```
**Solution:** Change PORT in .env or kill process using port 3000
### JWT Secret Warning
```
Warning: Using default JWT secret
```
**Solution:** Set JWT_SECRET in .env to a strong random string
## 📝 Notes
- Default customer role_id = 3
- Access token expires in 1 hour
- Refresh token expires in 7 days (or 1 day without "Remember Me")
- Password must contain uppercase, lowercase, and number
- Password minimum length: 8 characters

44
server/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "hotel-booking-server",
"version": "1.0.0",
"description": "Hotel booking backend server",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"migrate": "npx sequelize-cli db:migrate",
"migrate:undo": "npx sequelize-cli db:migrate:undo",
"seed": "npx sequelize-cli db:seed:all",
"seed:undo": "npx sequelize-cli db:seed:undo:all"
},
"keywords": [
"hotel",
"booking",
"management"
],
"author": "",
"license": "ISC",
"dependencies": {
"bcrypt": "^5.1.1",
"bcryptjs": "^3.0.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.6.5",
"nodemailer": "^7.0.10",
"sequelize": "^6.35.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"nodemon": "^3.0.2",
"sequelize-cli": "^6.6.3"
}
}

128
server/src/app.js Normal file
View File

@@ -0,0 +1,128 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const path = require('path');
// Import routes
const authRoutes = require('./routes/authRoutes');
const userRoutes = require('./routes/userRoutes');
const roomRoutes = require('./routes/roomRoutes');
const bookingRoutes = require('./routes/bookingRoutes');
const paymentRoutes = require('./routes/paymentRoutes');
const bannerRoutes = require('./routes/bannerRoutes');
const reviewRoutes = require('./routes/reviewRoutes');
const favoriteRoutes = require('./routes/favoriteRoutes');
const serviceRoutes = require('./routes/serviceRoutes');
const promotionRoutes = require('./routes/promotionRoutes');
const reportRoutes = require('./routes/reportRoutes');
// Import middleware
const errorHandler = require('./middlewares/errorHandler');
// Initialize app
const app = express();
// Security middleware
app.use(helmet({
crossOriginResourcePolicy: { policy: "cross-origin" } // Allow images to be loaded
}));
app.use(compression());
// CORS configuration
const corsOptions = {
origin: process.env.CLIENT_URL || 'http://localhost:5173',
credentials: true,
optionsSuccessStatus: 200
};
app.use(cors(corsOptions));
// Serve static files (uploads)
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// Body parser middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Cookie parser middleware
app.use(cookieParser());
// Logging middleware
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
} else {
app.use(morgan('combined'));
}
// Rate limiting - completely disabled in development, strict in production
const isDevelopment = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
if (!isDevelopment) {
// Strict rate limit for production only
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP',
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/', limiter);
console.log('🔒 Rate limiting enabled with production settings');
} else {
// No rate limiting middleware applied in development
// This allows unlimited requests during development
console.log('⚠️ Rate limiting DISABLED for development mode - unlimited requests allowed');
}
// Disable client-side caching for API responses to avoid 304
// responses for dynamic endpoints. This forces fresh data for
// API calls and prevents stale cached responses in browsers.
app.use('/api', (req, res, next) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
res.set('Surrogate-Control', 'no-store');
next();
});
// Static files
app.use('/uploads', express.static('uploads'));
// Health check
app.get('/health', (req, res) => {
res.status(200).json({
status: 'success',
message: 'Server is running',
timestamp: new Date().toISOString()
});
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/rooms', roomRoutes);
app.use('/api/bookings', bookingRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/banners', bannerRoutes);
app.use('/api/favorites', favoriteRoutes);
app.use('/api/services', serviceRoutes);
app.use('/api/promotions', promotionRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/reviews', reviewRoutes);
// 404 handler
app.use('*', (req, res) => {
res.status(404).json({
status: 'error',
message: `Route ${req.originalUrl} not found`
});
});
// Global error handler
app.use(errorHandler);
module.exports = app;

View File

@@ -0,0 +1,70 @@
require('dotenv').config();
module.exports = {
development: {
username: process.env.DB_USER || 'root',
password: process.env.DB_PASS || '',
database: process.env.DB_NAME || 'hotel_db',
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
dialect: 'mysql',
logging: console.log,
timezone: '+07:00',
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
},
define: {
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
},
test: {
username: process.env.DB_USER || 'root',
password: process.env.DB_PASS || '',
database: process.env.DB_NAME_TEST || 'hotel_db_test',
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
dialect: 'mysql',
logging: false,
timezone: '+07:00',
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
},
define: {
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
},
production: {
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: process.env.DB_PORT || 3306,
dialect: 'mysql',
logging: false,
timezone: '+07:00',
pool: {
max: 10,
min: 2,
acquire: 30000,
idle: 10000
},
define: {
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
}
};

View File

@@ -0,0 +1,244 @@
const authService = require('../services/authService');
/**
* Register new user
*/
const register = async (req, res, next) => {
try {
const { name, email, password, phone } = req.body;
const result = await authService.register({
name,
email,
password,
phone
});
// Set refresh token as HttpOnly cookie (default 7 days for new users)
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/'
};
res.cookie('refreshToken', result.refreshToken, cookieOptions);
// Don't send refreshToken in response body
const { refreshToken, ...dataWithoutRefreshToken } = result;
res.status(201).json({
status: 'success',
message: 'User registered successfully',
data: dataWithoutRefreshToken
});
} catch (error) {
if (error.message === 'Email already registered') {
return res.status(400).json({
status: 'error',
message: error.message
});
}
next(error);
}
};
/**
* Login user
*/
const login = async (req, res, next) => {
try {
const { email, password, rememberMe } = req.body;
const result = await authService.login({
email,
password,
rememberMe
});
// Set refresh token as HttpOnly cookie
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: rememberMe
? 7 * 24 * 60 * 60 * 1000
: 1 * 24 * 60 * 60 * 1000,
path: '/'
};
res.cookie('refreshToken', result.refreshToken, cookieOptions);
// Don't send refreshToken in response body
const { refreshToken, ...dataWithoutRefreshToken } = result;
res.status(200).json({
status: 'success',
message: 'Login successful',
data: dataWithoutRefreshToken
});
} catch (error) {
if (error.message === 'Invalid email or password') {
return res.status(401).json({
status: 'error',
message: error.message
});
}
next(error);
}
};
/**
* Refresh access token
*/
const refreshAccessToken = async (req, res, next) => {
try {
// Get refresh token from cookie instead of body
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({
status: 'error',
message: 'Refresh token not found'
});
}
const result = await authService.refreshAccessToken(refreshToken);
res.status(200).json({
status: 'success',
data: result
});
} catch (error) {
if (error.name === 'TokenExpiredError' ||
error.message.includes('token')) {
// Clear invalid cookie
res.clearCookie('refreshToken');
return res.status(401).json({
status: 'error',
message: error.message
});
}
next(error);
}
};
/**
* Logout user
*/
const logout = async (req, res, next) => {
try {
// Get refresh token from cookie
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
await authService.logout(refreshToken);
}
// Clear refresh token cookie
res.clearCookie('refreshToken', { path: '/' });
res.status(200).json({
status: 'success',
message: 'Logout successful'
});
} catch (error) {
next(error);
}
};
/**
* Get current user profile
*/
const getProfile = async (req, res, next) => {
try {
const user = await authService.getProfile(req.user.id);
res.status(200).json({
status: 'success',
data: {
user
}
});
} catch (error) {
if (error.message === 'User not found') {
return res.status(404).json({
status: 'error',
message: error.message
});
}
next(error);
}
};
/**
* Forgot Password - Send reset link
*/
const forgotPassword = async (req, res, next) => {
try {
const { email } = req.body;
const result = await authService.forgotPassword(email);
res.status(200).json({
status: 'success',
message: result.message
});
} catch (error) {
next(error);
}
};
/**
* Reset Password - Update password with token
*/
const resetPassword = async (req, res, next) => {
try {
const { token, password } = req.body;
const result = await authService.resetPassword({
token,
password
});
res.status(200).json({
status: 'success',
message: result.message
});
} catch (error) {
if (error.message.includes('token') ||
error.message.includes('required')) {
return res.status(400).json({
status: 'error',
message: error.message
});
}
if (error.message === 'User not found') {
return res.status(404).json({
status: 'error',
message: error.message
});
}
if (
error.message.includes('must be different') ||
error.message.includes('Mật khẩu mới')
) {
return res.status(400).json({
status: 'error',
message: error.message
});
}
next(error);
}
};
module.exports = {
register,
login,
refreshAccessToken,
logout,
getProfile,
forgotPassword,
resetPassword
};

View File

@@ -0,0 +1,222 @@
const { Banner } = require('../databases/models');
const { Op } = require('sequelize');
/**
* Get all banners with filters
*/
const getBanners = async (req, res, next) => {
try {
const { position } = req.query;
const whereClause = {
is_active: true,
};
// Filter by position if provided
if (position) {
whereClause.position = position;
}
// Get current date for filtering active banners
const now = new Date();
whereClause[Op.or] = [
{ start_date: null },
{ start_date: { [Op.lte]: now } },
];
whereClause[Op.and] = [
{
[Op.or]: [
{ end_date: null },
{ end_date: { [Op.gte]: now } },
],
},
];
const banners = await Banner.findAll({
where: whereClause,
order: [
['display_order', 'ASC'],
['created_at', 'DESC'],
],
});
// Ensure image_url is absolute so frontend can load directly
const baseUrl = process.env.SERVER_URL || `http://${req.get('host')}`;
const mapped = banners.map((b) => {
const obj = b.toJSON ? b.toJSON() : b;
if (obj.image_url && !/^https?:\/\//i.test(obj.image_url)) {
obj.image_url = obj.image_url.startsWith('/')
? `${baseUrl}${obj.image_url}`
: `${baseUrl}/${obj.image_url}`;
}
return obj;
});
res.status(200).json({
status: 'success',
data: {
banners: mapped,
},
});
} catch (error) {
next(error);
}
};
/**
* Get banner by ID
*/
const getBannerById = async (req, res, next) => {
try {
const { id } = req.params;
const banner = await Banner.findByPk(id);
if (!banner) {
return res.status(404).json({
status: 'error',
message: 'Banner not found',
});
}
// Prefix image_url to full URL for client convenience
const baseUrl = process.env.SERVER_URL || `http://${req.get('host')}`;
const out = banner.toJSON ? banner.toJSON() : banner;
if (out.image_url && !/^https?:\/\//i.test(out.image_url)) {
out.image_url = out.image_url.startsWith('/')
? `${baseUrl}${out.image_url}`
: `${baseUrl}/${out.image_url}`;
}
res.status(200).json({
status: 'success',
data: {
banner: out,
},
});
} catch (error) {
next(error);
}
};
/**
* Create new banner (Admin only)
*/
const createBanner = async (req, res, next) => {
try {
const {
title,
image_url,
link,
position,
display_order,
start_date,
end_date,
} = req.body;
const banner = await Banner.create({
title,
image_url,
link,
position: position || 'home',
display_order: display_order || 0,
is_active: true,
start_date,
end_date,
});
res.status(201).json({
status: 'success',
message: 'Banner created successfully',
data: {
banner,
},
});
} catch (error) {
next(error);
}
};
/**
* Update banner (Admin only)
*/
const updateBanner = async (req, res, next) => {
try {
const { id } = req.params;
const {
title,
image_url,
link,
position,
display_order,
is_active,
start_date,
end_date,
} = req.body;
const banner = await Banner.findByPk(id);
if (!banner) {
return res.status(404).json({
status: 'error',
message: 'Banner not found',
});
}
await banner.update({
title,
image_url,
link,
position,
display_order,
is_active,
start_date,
end_date,
});
res.status(200).json({
status: 'success',
message: 'Banner updated successfully',
data: {
banner,
},
});
} catch (error) {
next(error);
}
};
/**
* Delete banner (Admin only)
*/
const deleteBanner = async (req, res, next) => {
try {
const { id } = req.params;
const banner = await Banner.findByPk(id);
if (!banner) {
return res.status(404).json({
status: 'error',
message: 'Banner not found',
});
}
await banner.destroy();
res.status(200).json({
status: 'success',
message: 'Banner deleted successfully',
});
} catch (error) {
next(error);
}
};
module.exports = {
getBanners,
getBannerById,
createBanner,
updateBanner,
deleteBanner,
};

View File

@@ -0,0 +1,363 @@
const { Booking, Room, RoomType, Payment, User, sequelize, Sequelize } = require('../databases/models');
const { Op } = require('sequelize');
// Helper to generate a simple booking number
const generateBookingNumber = () => {
const prefix = 'BK';
const ts = Date.now();
const rand = Math.floor(Math.random() * 9000) + 1000;
return `${prefix}-${ts}-${rand}`;
};
/**
* Create a new booking
* POST /api/bookings
*/
const createBooking = async (req, res, next) => {
const t = await sequelize.transaction();
try {
const user = req.user;
const {
room_id,
check_in_date,
check_out_date,
guest_count,
total_price,
notes,
payment_method = 'cash',
} = req.body;
if (!room_id || !check_in_date || !check_out_date || !total_price) {
return res.status(400).json({
status: 'error',
message: 'Missing required booking fields',
});
}
// Ensure room exists
const room = await Room.findByPk(room_id, {
include: [{ model: RoomType, as: 'room_type' }],
});
if (!room) {
await t.rollback();
return res.status(404).json({ status: 'error', message: 'Room not found' });
}
// Check for overlapping bookings (exclude cancelled)
const overlapping = await Booking.findOne({
where: {
room_id,
status: { [Op.ne]: 'cancelled' },
[Op.and]: [
{ check_in_date: { [Op.lt]: new Date(check_out_date) } },
{ check_out_date: { [Op.gt]: new Date(check_in_date) } },
],
},
});
if (overlapping) {
await t.rollback();
return res.status(409).json({
status: 'error',
message: 'Room already booked for the selected dates',
});
}
const bookingNumber = generateBookingNumber();
// Determine if deposit is required (cash payment requires 20% deposit)
const requiresDeposit = payment_method === 'cash';
const depositPercentage = requiresDeposit ? 20 : 0;
const depositAmount = requiresDeposit ? (total_price * depositPercentage) / 100 : 0;
const booking = await Booking.create(
{
booking_number: bookingNumber,
user_id: user.id,
room_id,
check_in_date: new Date(check_in_date),
check_out_date: new Date(check_out_date),
num_guests: guest_count || 1,
total_price,
special_requests: notes || null,
status: 'pending',
requires_deposit: requiresDeposit,
deposit_paid: false,
},
{ transaction: t }
);
// Create deposit payment record if required
if (requiresDeposit) {
await Payment.create(
{
booking_id: booking.id,
amount: depositAmount,
payment_method: 'bank_transfer', // Deposit must be paid online
payment_type: 'deposit',
deposit_percentage: depositPercentage,
payment_status: 'pending',
notes: `Deposit payment (${depositPercentage}%) for booking ${bookingNumber}`,
},
{ transaction: t }
);
}
await t.commit();
// Fetch booking with payment info
const bookingWithPayments = await Booking.findByPk(booking.id, {
include: [
{ model: Room, as: 'room', include: [{ model: RoomType, as: 'room_type' }] },
{ model: Payment, as: 'payments' },
],
});
return res.status(201).json({
success: true,
data: {
booking: bookingWithPayments,
},
message: requiresDeposit
? `Booking created. Please pay ${depositPercentage}% deposit to confirm.`
: 'Booking created successfully',
});
} catch (error) {
await t.rollback();
next(error);
}
};
/**
* Get bookings for current user
* GET /api/bookings/me
*/
const getMyBookings = async (req, res, next) => {
try {
const user = req.user;
const bookings = await Booking.findAll({
where: { user_id: user.id },
include: [
{
model: Room,
as: 'room',
include: [{ model: RoomType, as: 'room_type' }],
},
],
order: [['created_at', 'DESC']],
});
res.status(200).json({ success: true, data: { bookings } });
} catch (error) {
next(error);
}
};
/**
* Get booking by id
* GET /api/bookings/:id
*/
const getBookingById = async (req, res, next) => {
try {
const { id } = req.params;
const booking = await Booking.findByPk(id, {
include: [
{ model: Room, as: 'room', include: [{ model: RoomType, as: 'room_type' }] },
{ model: Payment, as: 'payments' },
],
});
if (!booking) {
return res.status(404).json({ success: false, message: 'Booking not found' });
}
// If user is not owner, restrict access (admins may be allowed later)
if (req.user && booking.user_id !== req.user.id) {
return res.status(403).json({ status: 'error', message: 'Forbidden' });
}
res.status(200).json({ success: true, data: { booking } });
} catch (error) {
next(error);
}
};
/**
* Cancel a booking
* PATCH /api/bookings/:id/cancel
*/
const cancelBooking = async (req, res, next) => {
try {
const { id } = req.params;
const booking = await Booking.findByPk(id);
if (!booking) {
return res.status(404).json({ success: false, message: 'Booking not found' });
}
if (booking.user_id !== req.user.id) {
return res.status(403).json({ status: 'error', message: 'Forbidden' });
}
if (booking.status === 'cancelled') {
return res.status(400).json({ status: 'error', message: 'Booking already cancelled' });
}
booking.status = 'cancelled';
await booking.save();
res.status(200).json({ success: true, data: { booking } });
} catch (error) {
next(error);
}
};
/**
* Check booking by booking number
* GET /api/bookings/check/:bookingNumber
*/
const checkBookingByNumber = async (req, res, next) => {
try {
const { bookingNumber } = req.params;
const booking = await Booking.findOne({
where: { booking_number: bookingNumber },
include: [{ model: Room, as: 'room' }],
});
if (!booking) {
return res.status(404).json({ status: 'error', message: 'Booking not found' });
}
res.status(200).json({ status: 'success', data: { booking } });
} catch (error) {
next(error);
}
};
/**
* Get all bookings (Admin only)
* GET /api/bookings
*/
const getAllBookings = async (req, res, next) => {
try {
const {
search,
status,
startDate,
endDate,
page = 1,
limit = 10,
} = req.query;
const whereClause = {};
// Filter by search (booking_number or user name/email)
if (search) {
whereClause[Op.or] = [
{ booking_number: { [Op.like]: `%${search}%` } },
];
}
// Filter by status
if (status) {
whereClause.status = status;
}
// Filter by date range
if (startDate || endDate) {
whereClause.check_in_date = {};
if (startDate) {
whereClause.check_in_date[Op.gte] = new Date(startDate);
}
if (endDate) {
whereClause.check_in_date[Op.lte] = new Date(endDate);
}
}
const offset = (parseInt(page) - 1) * parseInt(limit);
const { count, rows: bookings } = await Booking.findAndCountAll({
where: whereClause,
include: [
{
model: User,
as: 'user',
attributes: ['id', 'full_name', 'email', 'phone'],
},
{
model: Room,
as: 'room',
attributes: ['id', 'room_number', 'floor'],
},
],
limit: parseInt(limit),
offset: offset,
order: [['created_at', 'DESC']],
});
res.status(200).json({
status: 'success',
data: {
bookings,
pagination: {
total: count,
page: parseInt(page),
limit: parseInt(limit),
totalPages: Math.ceil(count / parseInt(limit)),
},
},
});
} catch (error) {
next(error);
}
};
/**
* Update booking status (Admin only)
* PUT /api/bookings/:id
*/
const updateBooking = async (req, res, next) => {
try {
const { id } = req.params;
const { status } = req.body;
const booking = await Booking.findByPk(id);
if (!booking) {
return res.status(404).json({
status: 'error',
message: 'Booking not found',
});
}
// Validate status transition
const validStatuses = ['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled', 'completed'];
if (!validStatuses.includes(status)) {
return res.status(400).json({
status: 'error',
message: 'Invalid status',
});
}
// Update booking
await booking.update({ status });
res.status(200).json({
status: 'success',
message: 'Booking updated successfully',
data: { booking },
});
} catch (error) {
next(error);
}
};
module.exports = {
createBooking,
getMyBookings,
getBookingById,
cancelBooking,
checkBookingByNumber,
getAllBookings,
updateBooking,
};

View File

@@ -0,0 +1,205 @@
const {
Favorite,
Room,
RoomType,
Review,
Sequelize
} = require('../databases/models');
/**
* Add room to favorites
*/
const addFavorite = async (req, res, next) => {
try {
const userId = req.user.id;
const { roomId } = req.params;
// Check if room exists
const room = await Room.findByPk(roomId);
if (!room) {
return res.status(404).json({
status: 'error',
message: 'Không tìm thấy phòng',
});
}
// Check if already favorited
const existingFavorite = await Favorite.findOne({
where: {
user_id: userId,
room_id: roomId,
},
});
if (existingFavorite) {
return res.status(400).json({
status: 'error',
message: 'Phòng đã có trong danh sách yêu thích',
});
}
// Create favorite
const favorite = await Favorite.create({
user_id: userId,
room_id: roomId,
});
res.status(201).json({
status: 'success',
message: 'Đã thêm vào danh sách yêu thích',
data: {
favorite,
},
});
} catch (error) {
next(error);
}
};
/**
* Remove room from favorites
*/
const removeFavorite = async (req, res, next) => {
try {
const userId = req.user.id;
const { roomId } = req.params;
// Find and delete favorite
const favorite = await Favorite.findOne({
where: {
user_id: userId,
room_id: roomId,
},
});
if (!favorite) {
return res.status(404).json({
status: 'error',
message: 'Không tìm thấy phòng trong danh sách yêu thích',
});
}
await favorite.destroy();
res.status(200).json({
status: 'success',
message: 'Đã xóa khỏi danh sách yêu thích',
});
} catch (error) {
next(error);
}
};
/**
* Get user's favorite rooms
*/
const getFavorites = async (req, res, next) => {
try {
const userId = req.user.id;
// Get all favorites with room details
const favorites = await Favorite.findAll({
where: { user_id: userId },
include: [
{
model: Room,
as: 'room',
include: [
{
model: RoomType,
as: 'room_type',
},
],
},
],
order: [['created_at', 'DESC']],
});
// Get ratings for each room
const roomsWithRatings = await Promise.all(
favorites.map(async (favorite) => {
const room = favorite.room;
if (!room) {
return favorite.toJSON();
}
const reviewStats = await Review.findOne({
where: {
room_id: room.id,
status: 'approved',
},
attributes: [
[
Sequelize.fn('AVG', Sequelize.col('rating')),
'average_rating',
],
[
Sequelize.fn('COUNT', Sequelize.col('id')),
'total_reviews',
],
],
raw: true,
});
return {
...favorite.toJSON(),
room: {
...room.toJSON(),
average_rating: reviewStats?.average_rating
? Math.round(
parseFloat(reviewStats.average_rating) * 10
) / 10
: null,
total_reviews: reviewStats?.total_reviews
? parseInt(reviewStats.total_reviews, 10)
: 0,
},
};
})
);
res.status(200).json({
status: 'success',
data: {
favorites: roomsWithRatings,
total: roomsWithRatings.length,
},
});
} catch (error) {
next(error);
}
};
/**
* Check if room is favorited by user
*/
const checkFavorite = async (req, res, next) => {
try {
const userId = req.user.id;
const { roomId } = req.params;
const favorite = await Favorite.findOne({
where: {
user_id: userId,
room_id: roomId,
},
});
res.status(200).json({
status: 'success',
data: {
isFavorited: !!favorite,
},
});
} catch (error) {
next(error);
}
};
module.exports = {
addFavorite,
removeFavorite,
getFavorites,
checkFavorite,
};

Binary file not shown.

View File

@@ -0,0 +1,353 @@
const { Promotion } = require('../databases/models');
const { Op } = require('sequelize');
/**
* Get all promotions with filters and pagination
* GET /api/promotions
*/
const getPromotions = async (req, res, next) => {
try {
const {
search,
status,
type,
page = 1,
limit = 10,
} = req.query;
const whereClause = {};
// Filter by search (code or name)
if (search) {
whereClause[Op.or] = [
{ code: { [Op.like]: `%${search}%` } },
{ name: { [Op.like]: `%${search}%` } },
];
}
// Filter by status
if (status) {
whereClause.status = status;
}
// Filter by type
if (type) {
whereClause.discount_type = type;
}
const offset = (parseInt(page) - 1) * parseInt(limit);
const { count, rows: promotions } = await Promotion.findAndCountAll({
where: whereClause,
limit: parseInt(limit),
offset: offset,
order: [['created_at', 'DESC']],
});
res.status(200).json({
status: 'success',
data: {
promotions,
pagination: {
total: count,
page: parseInt(page),
limit: parseInt(limit),
totalPages: Math.ceil(count / parseInt(limit)),
},
},
});
} catch (error) {
next(error);
}
};
/**
* Get promotion by ID
* GET /api/promotions/:id
*/
const getPromotionById = async (req, res, next) => {
try {
const { id } = req.params;
const promotion = await Promotion.findByPk(id);
if (!promotion) {
return res.status(404).json({
status: 'error',
message: 'Promotion not found',
});
}
res.status(200).json({
status: 'success',
data: {
promotion,
},
});
} catch (error) {
next(error);
}
};
/**
* Create new promotion
* POST /api/promotions
*/
const createPromotion = async (req, res, next) => {
try {
const {
code,
name,
description,
discount_type,
discount_value,
min_booking_amount,
max_discount_amount,
start_date,
end_date,
usage_limit,
status = 'active',
} = req.body;
// Check if promotion code already exists
const existingPromotion = await Promotion.findOne({ where: { code } });
if (existingPromotion) {
return res.status(400).json({
status: 'error',
message: 'Promotion code already exists',
});
}
// Validate discount value
if (discount_type === 'percentage' && discount_value > 100) {
return res.status(400).json({
status: 'error',
message: 'Percentage discount cannot exceed 100%',
});
}
const promotion = await Promotion.create({
code,
name,
description,
discount_type,
discount_value,
min_booking_amount,
max_discount_amount,
start_date,
end_date,
usage_limit,
used_count: 0,
status,
});
res.status(201).json({
status: 'success',
message: 'Promotion created successfully',
data: {
promotion,
},
});
} catch (error) {
next(error);
}
};
/**
* Update promotion
* PUT /api/promotions/:id
*/
const updatePromotion = async (req, res, next) => {
try {
const { id } = req.params;
const {
code,
name,
description,
discount_type,
discount_value,
min_booking_amount,
max_discount_amount,
start_date,
end_date,
usage_limit,
status,
} = req.body;
const promotion = await Promotion.findByPk(id);
if (!promotion) {
return res.status(404).json({
status: 'error',
message: 'Promotion not found',
});
}
// Check if new code already exists (excluding current promotion)
if (code && code !== promotion.code) {
const existingPromotion = await Promotion.findOne({
where: {
code,
id: { [Op.ne]: id },
},
});
if (existingPromotion) {
return res.status(400).json({
status: 'error',
message: 'Promotion code already exists',
});
}
}
// Validate discount value
if (discount_type === 'percentage' && discount_value > 100) {
return res.status(400).json({
status: 'error',
message: 'Percentage discount cannot exceed 100%',
});
}
const updateData = {};
if (code !== undefined) updateData.code = code;
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (discount_type !== undefined) updateData.discount_type = discount_type;
if (discount_value !== undefined) updateData.discount_value = discount_value;
if (min_booking_amount !== undefined) updateData.min_booking_amount = min_booking_amount;
if (max_discount_amount !== undefined) updateData.max_discount_amount = max_discount_amount;
if (start_date !== undefined) updateData.start_date = start_date;
if (end_date !== undefined) updateData.end_date = end_date;
if (usage_limit !== undefined) updateData.usage_limit = usage_limit;
if (status !== undefined) updateData.status = status;
await promotion.update(updateData);
res.status(200).json({
status: 'success',
message: 'Promotion updated successfully',
data: {
promotion,
},
});
} catch (error) {
next(error);
}
};
/**
* Delete promotion
* DELETE /api/promotions/:id
*/
const deletePromotion = async (req, res, next) => {
try {
const { id } = req.params;
const promotion = await Promotion.findByPk(id);
if (!promotion) {
return res.status(404).json({
status: 'error',
message: 'Promotion not found',
});
}
await promotion.destroy();
res.status(200).json({
status: 'success',
message: 'Promotion deleted successfully',
});
} catch (error) {
next(error);
}
};
/**
* Validate and apply promotion
* POST /api/promotions/validate
*/
const validatePromotion = async (req, res, next) => {
try {
const { code, booking_amount } = req.body;
const promotion = await Promotion.findOne({ where: { code } });
if (!promotion) {
return res.status(404).json({
status: 'error',
message: 'Promotion code not found',
});
}
// Check if promotion is active
if (promotion.status !== 'active') {
return res.status(400).json({
status: 'error',
message: 'Promotion is not active',
});
}
// Check date validity
const now = new Date();
if (now < new Date(promotion.start_date) || now > new Date(promotion.end_date)) {
return res.status(400).json({
status: 'error',
message: 'Promotion is not valid at this time',
});
}
// Check usage limit
if (promotion.usage_limit && promotion.used_count >= promotion.usage_limit) {
return res.status(400).json({
status: 'error',
message: 'Promotion usage limit reached',
});
}
// Check minimum booking amount
if (booking_amount < promotion.min_booking_amount) {
return res.status(400).json({
status: 'error',
message: `Minimum booking amount is ${promotion.min_booking_amount}`,
});
}
// Calculate discount
let discount_amount = 0;
if (promotion.discount_type === 'percentage') {
discount_amount = (booking_amount * promotion.discount_value) / 100;
} else {
discount_amount = promotion.discount_value;
}
// Apply max discount limit
if (promotion.max_discount_amount && discount_amount > promotion.max_discount_amount) {
discount_amount = promotion.max_discount_amount;
}
const final_amount = booking_amount - discount_amount;
res.status(200).json({
status: 'success',
data: {
promotion: {
id: promotion.id,
code: promotion.code,
name: promotion.name,
},
original_amount: booking_amount,
discount_amount,
final_amount,
},
});
} catch (error) {
next(error);
}
};
module.exports = {
getPromotions,
getPromotionById,
createPromotion,
updatePromotion,
deletePromotion,
validatePromotion,
};

View File

@@ -0,0 +1,425 @@
const {
Booking,
Payment,
Room,
User,
Service,
BookingService,
sequelize,
Sequelize,
} = require('../databases/models');
const { Op } = require('sequelize');
/**
* Get dashboard statistics
* GET /api/reports/dashboard
*/
const getDashboardStats = async (req, res, next) => {
try {
const { startDate, endDate } = req.query;
const dateFilter = {};
if (startDate || endDate) {
dateFilter.created_at = {};
if (startDate) {
dateFilter.created_at[Op.gte] = new Date(startDate);
}
if (endDate) {
dateFilter.created_at[Op.lte] = new Date(endDate);
}
}
// Total revenue
const totalRevenue = await Payment.sum('amount', {
where: {
payment_status: 'completed',
...dateFilter,
},
});
// Total bookings
const totalBookings = await Booking.count({
where: dateFilter,
});
// Available rooms
const availableRooms = await Room.count({
where: { status: 'available' },
});
// Total customers
const totalCustomers = await User.count({
where: { role_id: 3 }, // 3 = customer
});
// Revenue by date (last 7 days or date range)
const revenueByDate = await Payment.findAll({
attributes: [
[sequelize.fn('DATE', sequelize.col('payment_date')), 'date'],
[sequelize.fn('SUM', sequelize.col('amount')), 'revenue'],
],
where: {
payment_status: 'completed',
...dateFilter,
},
group: [sequelize.fn('DATE', sequelize.col('payment_date'))],
order: [[sequelize.fn('DATE', sequelize.col('payment_date')), 'ASC']],
raw: true,
});
// Bookings by status
const bookingsByStatus = await Booking.findAll({
attributes: [
'status',
[sequelize.fn('COUNT', sequelize.col('id')), 'count'],
],
where: dateFilter,
group: ['status'],
raw: true,
});
// Top rooms (by booking count)
const topRooms = await Booking.findAll({
attributes: [
'room_id',
[sequelize.fn('COUNT', sequelize.col('Booking.id')), 'booking_count'],
[sequelize.fn('SUM', sequelize.col('total_price')), 'total_revenue'],
],
include: [
{
model: Room,
as: 'room',
attributes: ['id', 'room_number', 'floor'],
},
],
where: dateFilter,
group: ['room_id', 'room.id'],
order: [[sequelize.fn('COUNT', sequelize.col('Booking.id')), 'DESC']],
limit: 5,
raw: true,
nest: true,
});
// Service usage statistics
const serviceUsage = await BookingService.findAll({
attributes: [
'service_id',
[sequelize.fn('SUM', sequelize.col('quantity')), 'total_quantity'],
[sequelize.fn('SUM', sequelize.col('total_price')), 'total_revenue'],
],
include: [
{
model: Service,
as: 'service',
attributes: ['id', 'name', 'price', 'unit'],
},
],
group: ['service_id', 'service.id'],
order: [[sequelize.fn('SUM', sequelize.col('quantity')), 'DESC']],
limit: 5,
raw: true,
nest: true,
});
res.status(200).json({
status: 'success',
data: {
summary: {
total_revenue: totalRevenue || 0,
total_bookings: totalBookings,
available_rooms: availableRooms,
total_customers: totalCustomers,
},
revenue_by_date: revenueByDate,
bookings_by_status: bookingsByStatus,
top_rooms: topRooms,
service_usage: serviceUsage,
},
});
} catch (error) {
next(error);
}
};
/**
* Get detailed reports
* GET /api/reports
*/
const getReports = async (req, res, next) => {
try {
const {
type = 'revenue',
startDate,
endDate,
from, // Accept 'from' param
to, // Accept 'to' param
groupBy = 'day',
} = req.query;
const dateFilter = {};
const start = startDate || from;
const end = endDate || to;
if (start || end) {
dateFilter.created_at = {};
if (start) {
dateFilter.created_at[Op.gte] = new Date(start);
}
if (end) {
dateFilter.created_at[Op.lte] = new Date(end);
}
}
let reportData;
switch (type) {
case 'revenue':
reportData = await generateRevenueReport(dateFilter, groupBy);
break;
case 'bookings':
reportData = await generateBookingsReport(dateFilter, groupBy);
break;
case 'rooms':
reportData = await generateRoomsReport(dateFilter);
break;
case 'customers':
reportData = await generateCustomersReport(dateFilter);
break;
default:
return res.status(400).json({
status: 'error',
message: 'Invalid report type',
});
}
res.status(200).json({
status: 'success',
data: reportData,
});
} catch (error) {
console.error('Error in getReports:', error);
next(error);
}
};
/**
* Export report to CSV
* GET /api/reports/export
*/
const exportReport = async (req, res, next) => {
try {
const { type = 'revenue', startDate, endDate } = req.query;
const dateFilter = {};
if (startDate || endDate) {
dateFilter.payment_date = {};
if (startDate) {
dateFilter.payment_date[Op.gte] = new Date(startDate);
}
if (endDate) {
dateFilter.payment_date[Op.lte] = new Date(endDate);
}
}
let csvContent = '';
let filename = '';
switch (type) {
case 'revenue':
const payments = await Payment.findAll({
where: {
payment_status: 'completed',
...dateFilter,
},
include: [
{
model: Booking,
as: 'booking',
attributes: ['booking_number'],
include: [
{
model: User,
as: 'user',
attributes: ['full_name', 'email'],
},
],
},
],
order: [['payment_date', 'DESC']],
});
csvContent = 'Date,Booking Number,Customer,Payment Method,Amount\n';
payments.forEach((payment) => {
csvContent += `${payment.payment_date},${payment.booking.booking_number},${payment.booking.user.full_name},${payment.payment_method},${payment.amount}\n`;
});
filename = `revenue_report_${Date.now()}.csv`;
break;
case 'bookings':
const bookings = await Booking.findAll({
where: dateFilter,
include: [
{
model: User,
as: 'user',
attributes: ['full_name', 'email'],
},
{
model: Room,
as: 'room',
attributes: ['room_number'],
},
],
order: [['created_at', 'DESC']],
});
csvContent = 'Booking Number,Customer,Room,Check In,Check Out,Status,Total Price\n';
bookings.forEach((booking) => {
csvContent += `${booking.booking_number},${booking.user.full_name},${booking.room.room_number},${booking.check_in_date},${booking.check_out_date},${booking.status},${booking.total_price}\n`;
});
filename = `bookings_report_${Date.now()}.csv`;
break;
default:
return res.status(400).json({
status: 'error',
message: 'Invalid export type',
});
}
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.status(200).send(csvContent);
} catch (error) {
next(error);
}
};
// Helper functions
const generateRevenueReport = async (dateFilter, groupBy) => {
let dateFormat;
switch (groupBy) {
case 'month':
dateFormat = '%Y-%m';
break;
case 'week':
dateFormat = '%Y-%u';
break;
default:
dateFormat = '%Y-%m-%d';
}
const revenue = await Payment.findAll({
attributes: [
[sequelize.fn('DATE_FORMAT', sequelize.col('payment_date'), dateFormat), 'period'],
[sequelize.fn('SUM', sequelize.col('amount')), 'total_revenue'],
[sequelize.fn('COUNT', sequelize.col('id')), 'payment_count'],
],
where: {
payment_status: 'completed',
...dateFilter,
},
group: [sequelize.fn('DATE_FORMAT', sequelize.col('payment_date'), dateFormat)],
order: [[sequelize.fn('DATE_FORMAT', sequelize.col('payment_date'), dateFormat), 'ASC']],
raw: true,
});
return { revenue };
};
const generateBookingsReport = async (dateFilter, groupBy) => {
let dateFormat;
switch (groupBy) {
case 'month':
dateFormat = '%Y-%m';
break;
case 'week':
dateFormat = '%Y-%u';
break;
default:
dateFormat = '%Y-%m-%d';
}
const bookings = await Booking.findAll({
attributes: [
[sequelize.fn('DATE_FORMAT', sequelize.col('created_at'), dateFormat), 'period'],
'status',
[sequelize.fn('COUNT', sequelize.col('id')), 'count'],
],
where: dateFilter,
group: [
sequelize.fn('DATE_FORMAT', sequelize.col('created_at'), dateFormat),
'status',
],
order: [[sequelize.fn('DATE_FORMAT', sequelize.col('created_at'), dateFormat), 'ASC']],
raw: true,
});
return { bookings };
};
const generateRoomsReport = async (dateFilter) => {
const roomStats = await Room.findAll({
attributes: [
'id',
'room_number',
'status',
[
sequelize.literal(`(
SELECT COUNT(*)
FROM bookings
WHERE bookings.room_id = Room.id
)`),
'total_bookings',
],
[
sequelize.literal(`(
SELECT SUM(total_price)
FROM bookings
WHERE bookings.room_id = Room.id
)`),
'total_revenue',
],
],
order: [['room_number', 'ASC']],
});
return { rooms: roomStats };
};
const generateCustomersReport = async (dateFilter) => {
const customerStats = await User.findAll({
attributes: [
'id',
'full_name',
'email',
[
sequelize.literal(`(
SELECT COUNT(*)
FROM bookings
WHERE bookings.user_id = User.id
)`),
'total_bookings',
],
[
sequelize.literal(`(
SELECT SUM(total_price)
FROM bookings
WHERE bookings.user_id = User.id
)`),
'total_spent',
],
],
where: { role: 'customer' },
order: [[sequelize.literal('total_bookings'), 'DESC']],
limit: 50,
});
return { customers: customerStats };
};
module.exports = {
getDashboardStats,
getReports,
exportReport,
};

View File

@@ -0,0 +1,233 @@
const { Review, User, Room, Booking } =
require('../databases/models');
const { Op } = require('sequelize');
/**
* Get reviews for a specific room
*/
const getRoomReviews = async (req, res, next) => {
try {
// Support both routes: /api/reviews/room/:roomId and /api/rooms/:id/reviews
const roomId = req.params.roomId || req.params.id;
if (!roomId) {
return res.status(400).json({
status: 'error',
message: 'roomId is required',
});
}
const reviews = await Review.findAll({
where: {
room_id: parseInt(roomId, 10),
status: 'approved',
},
include: [
{
model: User,
as: 'user',
attributes: ['id', 'full_name', 'email'],
},
],
order: [['created_at', 'DESC']],
});
res.status(200).json({
status: 'success',
data: {
reviews,
},
});
} catch (error) {
next(error);
}
};
/**
* Create a new review (authenticated users only)
*/
const createReview = async (req, res, next) => {
try {
const { room_id, rating, comment } = req.body;
const userId = req.user.id;
// Check if room exists
const room = await Room.findByPk(room_id);
if (!room) {
return res.status(404).json({
status: 'error',
message: 'Room not found',
});
}
// Optional: Check if user has booked this room
// const hasBooked = await Booking.findOne({
// where: {
// user_id: userId,
// room_id: room_id,
// status: 'completed',
// },
// });
// if (!hasBooked) {
// return res.status(403).json({
// status: 'error',
// message: 'You can only review rooms you have booked',
// });
// }
// Check if user already reviewed this room
const existingReview = await Review.findOne({
where: {
user_id: userId,
room_id: room_id,
},
});
if (existingReview) {
return res.status(400).json({
status: 'error',
message: 'You have already reviewed this room',
});
}
// Create review
const review = await Review.create({
user_id: userId,
room_id,
rating,
comment,
status: 'pending', // Admin will approve
});
res.status(201).json({
status: 'success',
message: 'Review submitted successfully and is pending approval',
data: {
review,
},
});
} catch (error) {
next(error);
}
};
/**
* Approve review (Admin only)
*/
const approveReview = async (req, res, next) => {
try {
const { id } = req.params;
const review = await Review.findByPk(id);
if (!review) {
return res.status(404).json({
status: 'error',
message: 'Review not found',
});
}
await review.update({ status: 'approved' });
res.status(200).json({
status: 'success',
message: 'Review approved successfully',
data: {
review,
},
});
} catch (error) {
next(error);
}
};
/**
* Reject review (Admin only)
*/
const rejectReview = async (req, res, next) => {
try {
const { id } = req.params;
const review = await Review.findByPk(id);
if (!review) {
return res.status(404).json({
status: 'error',
message: 'Review not found',
});
}
await review.update({ status: 'rejected' });
res.status(200).json({
status: 'success',
message: 'Review rejected successfully',
data: {
review,
},
});
} catch (error) {
next(error);
}
};
/**
* Get all reviews (Admin only)
*/
const getAllReviews = async (req, res, next) => {
try {
const {
status,
page = 1,
limit = 10,
} = req.query;
const whereClause = {};
if (status) {
whereClause.status = status;
}
const offset = (parseInt(page) - 1) * parseInt(limit);
const { count, rows: reviews } = await Review.findAndCountAll({
where: whereClause,
include: [
{
model: User,
as: 'user',
attributes: ['id', 'full_name', 'email', 'phone'],
},
{
model: Room,
as: 'room',
attributes: ['id', 'room_number'],
},
],
limit: parseInt(limit),
offset: offset,
order: [['created_at', 'DESC']],
});
res.status(200).json({
status: 'success',
data: {
reviews,
pagination: {
total: count,
page: parseInt(page),
limit: parseInt(limit),
totalPages: Math.ceil(count / parseInt(limit)),
},
},
});
} catch (error) {
console.error('Error in getAllReviews:', error);
next(error);
}
};
module.exports = {
getRoomReviews,
createReview,
approveReview,
rejectReview,
getAllReviews,
};

View File

@@ -0,0 +1,720 @@
const { Room, RoomType, Review, sequelize, Sequelize } = require('../databases/models');
const { Op } = require('sequelize');
const path = require('path');
const fs = require('fs');
// Normalize image paths stored in DB (relative) to absolute URLs
const normalizeImages = (images, baseUrl) => {
if (!images) return [];
let imgs = images;
if (typeof images === 'string') {
try {
imgs = JSON.parse(images);
} catch (e) {
// comma separated?
imgs = images.split(',').map((s) => s.trim()).filter(Boolean);
}
}
if (!Array.isArray(imgs)) return [];
return imgs.map((img) => {
if (!img) return img;
if (/^https?:\/\//i.test(img)) return img;
// ensure leading slash
const pathPart = img.startsWith('/') ? img : `/${img}`;
return `${baseUrl}${pathPart}`;
});
};
/**
* Get all rooms with filters
*/
const getRooms = async (req, res, next) => {
try {
const {
type,
minPrice,
maxPrice,
capacity,
page = 1,
limit = 10,
sort,
featured,
} = req.query;
const whereClause = {};
const roomTypeWhere = {};
// Filter by featured
if (featured !== undefined) {
whereClause.featured =
featured === 'true' || featured === true;
}
// Filter by room type
if (type) {
roomTypeWhere.name = { [Op.like]: `%${type}%` };
}
// Filter by capacity
if (capacity) {
roomTypeWhere.capacity = { [Op.gte]: parseInt(capacity) };
}
// Filter by price
if (minPrice || maxPrice) {
roomTypeWhere.base_price = {};
if (minPrice) {
roomTypeWhere.base_price[Op.gte] =
parseFloat(minPrice);
}
if (maxPrice) {
roomTypeWhere.base_price[Op.lte] =
parseFloat(maxPrice);
}
}
// Pagination
const pageNum = parseInt(page);
const limitNum = parseInt(limit);
const offset = (pageNum - 1) * limitNum;
// Sorting: support `sort=newest` to order strictly by created_at
// Default: featured first then created_at desc
let order = [
['featured', 'DESC'],
['created_at', 'DESC'],
];
if (sort === 'newest' || sort === 'created_at') {
order = [['created_at', 'DESC']];
}
// Get rooms with room type and reviews
const { count, rows: rooms } = await Room.findAndCountAll({
where: whereClause,
include: [
{
model: RoomType,
as: 'room_type',
where: Object.keys(roomTypeWhere).length > 0
? roomTypeWhere
: undefined,
required: true,
},
],
limit: limitNum,
offset,
order,
distinct: true,
});
// compute base url for images
const baseUrl = process.env.SERVER_URL || `http://${req.get('host')}`;
// Get average rating for each room
const roomsWithRatings = await Promise.all(
rooms.map(async (room) => {
const reviewStats = await Review.findOne({
where: {
room_id: room.id,
status: 'approved',
},
attributes: [
[
Sequelize.fn('AVG', Sequelize.col('rating')),
'average_rating',
],
[
Sequelize.fn('COUNT', Sequelize.col('id')),
'total_reviews',
],
],
raw: true,
});
const item = {
...room.toJSON(),
average_rating: reviewStats?.average_rating
? Math.round(parseFloat(reviewStats.average_rating) * 10) / 10
: null,
total_reviews: reviewStats?.total_reviews
? parseInt(reviewStats.total_reviews, 10)
: 0,
};
// Normalize images for room and its room_type
try {
item.images = normalizeImages(item.images, baseUrl);
} catch (e) {
item.images = [];
}
if (item.room_type) {
try {
item.room_type.images = normalizeImages(
item.room_type.images,
baseUrl
);
} catch (e) {
item.room_type.images = [];
}
}
return item;
})
);
res.status(200).json({
status: 'success',
data: {
rooms: roomsWithRatings,
pagination: {
total: count,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(count / limitNum),
},
},
});
} catch (error) {
next(error);
}
};
/**
* Get room by ID
*/
const getRoomById = async (req, res, next) => {
try {
const { id } = req.params;
const room = await Room.findByPk(id, {
include: [
{
model: RoomType,
as: 'room_type',
},
],
});
if (!room) {
return res.status(404).json({
status: 'error',
message: 'Room not found',
});
}
// Get average rating
const reviewStats = await Review.findOne({
where: {
room_id: room.id,
status: 'approved',
},
attributes: [
[
Sequelize.fn('AVG', Sequelize.col('rating')),
'average_rating',
],
[
Sequelize.fn('COUNT', Sequelize.col('id')),
'total_reviews',
],
],
raw: true,
});
const baseUrl = process.env.SERVER_URL || `http://${req.get('host')}`;
const roomData = {
...room.toJSON(),
average_rating: reviewStats?.average_rating
? Math.round(parseFloat(reviewStats.average_rating) * 10) / 10
: null,
total_reviews: reviewStats?.total_reviews
? parseInt(reviewStats.total_reviews, 10)
: 0,
};
// Normalize images
try {
roomData.images = normalizeImages(roomData.images, baseUrl);
} catch (e) {
roomData.images = [];
}
if (roomData.room_type) {
try {
roomData.room_type.images = normalizeImages(
roomData.room_type.images,
baseUrl
);
} catch (e) {
roomData.room_type.images = [];
}
}
res.status(200).json({
status: 'success',
data: {
room: roomData,
},
});
} catch (error) {
next(error);
}
};
/**
* Get list of unique amenities from room_types and rooms
*/
const getAmenities = async (req, res, next) => {
try {
// Fetch amenities from RoomType and Room
const roomTypes = await Room.sequelize.models.RoomType.findAll({
attributes: ['amenities'],
raw: true,
});
const rooms = await Room.findAll({
attributes: ['amenities'],
raw: true,
});
const all = [];
const pushFromValue = (val) => {
if (!val) return;
if (Array.isArray(val)) {
val.forEach((v) => all.push(String(v).trim()));
} else if (typeof val === 'string') {
try {
const parsed = JSON.parse(val);
if (Array.isArray(parsed)) {
parsed.forEach((v) => all.push(String(v).trim()));
return;
}
} catch (e) {
// not JSON
}
// comma separated
val.split(',').forEach((v) => all.push(String(v).trim()));
} else if (typeof val === 'object') {
Object.values(val).forEach((v) => {
if (Array.isArray(v)) v.forEach((x) => all.push(String(x).trim()));
else all.push(String(v).trim());
});
}
};
roomTypes.forEach((rt) => pushFromValue(rt.amenities));
rooms.forEach((r) => pushFromValue(r.amenities));
// unique, filter empty
const unique = Array.from(new Set(all.map((s) => s))).filter(Boolean);
res.status(200).json({ status: 'success', data: { amenities: unique } });
} catch (error) {
next(error);
}
};
/**
* Search available rooms
*/
const searchAvailableRooms = async (req, res, next) => {
try {
const { from, to, type, capacity, page = 1, limit = 12 } = req.query;
if (!from || !to) {
return res.status(400).json({
status: 'error',
message: 'From and to dates are required',
});
}
const checkInDate = new Date(from);
const checkOutDate = new Date(to);
if (checkInDate >= checkOutDate) {
return res.status(400).json({
status: 'error',
message: 'Check-out date must be after check-in date',
});
}
// Build room type filter
const roomTypeWhere = {};
if (type) {
roomTypeWhere.name = { [Op.like]: `%${type}%` };
}
if (capacity) {
roomTypeWhere.capacity = { [Op.gte]: parseInt(capacity) };
}
// Pagination params
const pageNum = parseInt(page, 10) || 1;
const limitNum = parseInt(limit, 10) || 12;
const offset = (pageNum - 1) * limitNum;
// Get available rooms with pagination
const { count, rows: availableRooms } = await Room.findAndCountAll({
where: {
status: 'available',
},
include: [
{
model: RoomType,
as: 'room_type',
where: Object.keys(roomTypeWhere).length > 0
? roomTypeWhere
: undefined,
},
],
limit: limitNum,
offset,
order: [['featured', 'DESC'], ['created_at', 'DESC']],
distinct: true,
});
// compute base url for images
const baseUrl = process.env.SERVER_URL || `http://${req.get('host')}`;
// Get ratings for available rooms
const roomsWithRatings = await Promise.all(
availableRooms.map(async (room) => {
const reviewStats = await Review.findOne({
where: {
room_id: room.id,
status: 'approved',
},
attributes: [
[
sequelize.fn('AVG', sequelize.col('rating')),
'average_rating',
],
[
sequelize.fn('COUNT', sequelize.col('id')),
'total_reviews',
],
],
raw: true,
});
const item = {
...room.toJSON(),
average_rating: reviewStats?.average_rating
? Math.round(parseFloat(reviewStats.average_rating) * 10) / 10
: null,
total_reviews: reviewStats?.total_reviews
? parseInt(reviewStats.total_reviews, 10)
: 0,
};
// Normalize images
try {
item.images = normalizeImages(item.images, baseUrl);
} catch (e) {
item.images = [];
}
if (item.room_type) {
try {
item.room_type.images = normalizeImages(
item.room_type.images,
baseUrl
);
} catch (e) {
item.room_type.images = [];
}
}
return item;
})
);
res.status(200).json({
status: 'success',
data: {
rooms: roomsWithRatings,
search: {
from,
to,
type,
capacity,
},
pagination: {
total: count,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(count / limitNum),
},
},
});
} catch (error) {
next(error);
}
};
/**
* Create new room (Admin only)
*/
const createRoom = async (req, res, next) => {
try {
const {
room_type_id,
room_number,
floor,
status,
featured,
} = req.body;
// Check if room type exists
const roomType = await RoomType.findByPk(room_type_id);
if (!roomType) {
return res.status(404).json({
status: 'error',
message: 'Room type not found',
});
}
// Check if room number already exists
const existingRoom = await Room.findOne({
where: { room_number },
});
if (existingRoom) {
return res.status(400).json({
status: 'error',
message: 'Room number already exists',
});
}
const room = await Room.create({
room_type_id,
room_number,
floor,
status: status || 'available',
featured: featured || false,
});
res.status(201).json({
status: 'success',
message: 'Room created successfully',
data: {
room,
},
});
} catch (error) {
next(error);
}
};
/**
* Update room (Admin only)
*/
const updateRoom = async (req, res, next) => {
try {
const { id } = req.params;
const {
room_type_id,
room_number,
floor,
status,
featured,
} = req.body;
const room = await Room.findByPk(id);
if (!room) {
return res.status(404).json({
status: 'error',
message: 'Room not found',
});
}
// Check if room type exists if updating
if (room_type_id) {
const roomType = await RoomType.findByPk(room_type_id);
if (!roomType) {
return res.status(404).json({
status: 'error',
message: 'Room type not found',
});
}
}
await room.update({
room_type_id,
room_number,
floor,
status,
featured,
});
res.status(200).json({
status: 'success',
message: 'Room updated successfully',
data: {
room,
},
});
} catch (error) {
next(error);
}
};
/**
* Delete room (Admin only)
*/
const deleteRoom = async (req, res, next) => {
try {
const { id } = req.params;
const room = await Room.findByPk(id);
if (!room) {
return res.status(404).json({
status: 'error',
message: 'Room not found',
});
}
await room.destroy();
res.status(200).json({
status: 'success',
message: 'Room deleted successfully',
});
} catch (error) {
next(error);
}
};
/**
* Upload room images
* POST /api/rooms/:id/images
*/
const uploadRoomImages = async (req, res, next) => {
try {
const { id } = req.params;
const room = await Room.findByPk(id, {
include: [
{
model: RoomType,
as: 'room_type',
},
],
});
if (!room) {
// Delete uploaded files if room not found
if (req.files) {
req.files.forEach(file => {
fs.unlinkSync(file.path);
});
}
return res.status(404).json({
status: 'error',
message: 'Room not found',
});
}
// Get uploaded file URLs
const imageUrls = req.files.map(file => `/uploads/rooms/${file.filename}`);
// Get existing images from room_type
const existingImages = room.room_type.images || [];
// Append new images
const updatedImages = [...existingImages, ...imageUrls];
// Update room_type images
await room.room_type.update({
images: updatedImages,
});
res.status(200).json({
status: 'success',
message: 'Images uploaded successfully',
data: {
images: updatedImages,
},
});
} catch (error) {
// Clean up uploaded files on error
if (req.files) {
req.files.forEach(file => {
try {
fs.unlinkSync(file.path);
} catch (err) {
console.error('Error deleting file:', err);
}
});
}
next(error);
}
};
/**
* Delete room image
* DELETE /api/rooms/:id/images
*/
const deleteRoomImage = async (req, res, next) => {
try {
const { id } = req.params;
const { imageUrl } = req.body;
const room = await Room.findByPk(id, {
include: [
{
model: RoomType,
as: 'room_type',
},
],
});
if (!room) {
return res.status(404).json({
status: 'error',
message: 'Room not found',
});
}
// Get existing images
const existingImages = room.room_type.images || [];
// Remove the specified image
const updatedImages = existingImages.filter(img => img !== imageUrl);
// Delete file from disk
const filename = path.basename(imageUrl);
const filePath = path.join(__dirname, '../../uploads/rooms', filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// Update room_type images
await room.room_type.update({
images: updatedImages,
});
res.status(200).json({
status: 'success',
message: 'Image deleted successfully',
data: {
images: updatedImages,
},
});
} catch (error) {
next(error);
}
};
module.exports = {
getRooms,
getRoomById,
getAmenities,
searchAvailableRooms,
createRoom,
updateRoom,
deleteRoom,
uploadRoomImages,
deleteRoomImage,
};

View File

@@ -0,0 +1,298 @@
const { Service, BookingService, Booking, sequelize } = require('../databases/models');
const { Op } = require('sequelize');
/**
* Get all services with filters and pagination
* GET /api/services
*/
const getServices = async (req, res, next) => {
try {
const {
search,
status,
page = 1,
limit = 10,
} = req.query;
const whereClause = {};
// Filter by search (name or description)
if (search) {
whereClause[Op.or] = [
{ name: { [Op.like]: `%${search}%` } },
{ description: { [Op.like]: `%${search}%` } },
];
}
// Filter by status
if (status) {
whereClause.status = status;
}
const offset = (parseInt(page) - 1) * parseInt(limit);
const { count, rows: services } = await Service.findAndCountAll({
where: whereClause,
limit: parseInt(limit),
offset: offset,
order: [['created_at', 'DESC']],
});
res.status(200).json({
status: 'success',
data: {
services,
pagination: {
total: count,
page: parseInt(page),
limit: parseInt(limit),
totalPages: Math.ceil(count / parseInt(limit)),
},
},
});
} catch (error) {
next(error);
}
};
/**
* Get service by ID
* GET /api/services/:id
*/
const getServiceById = async (req, res, next) => {
try {
const { id } = req.params;
const service = await Service.findByPk(id);
if (!service) {
return res.status(404).json({
status: 'error',
message: 'Service not found',
});
}
res.status(200).json({
status: 'success',
data: {
service,
},
});
} catch (error) {
next(error);
}
};
/**
* Create new service
* POST /api/services
*/
const createService = async (req, res, next) => {
try {
const {
name,
description,
price,
unit,
status = 'active',
} = req.body;
// Check if service name already exists
const existingService = await Service.findOne({ where: { name } });
if (existingService) {
return res.status(400).json({
status: 'error',
message: 'Service name already exists',
});
}
const service = await Service.create({
name,
description,
price,
unit,
status,
});
res.status(201).json({
status: 'success',
message: 'Service created successfully',
data: {
service,
},
});
} catch (error) {
next(error);
}
};
/**
* Update service
* PUT /api/services/:id
*/
const updateService = async (req, res, next) => {
try {
const { id } = req.params;
const {
name,
description,
price,
unit,
status,
} = req.body;
const service = await Service.findByPk(id);
if (!service) {
return res.status(404).json({
status: 'error',
message: 'Service not found',
});
}
// Check if new name already exists (excluding current service)
if (name && name !== service.name) {
const existingService = await Service.findOne({
where: {
name,
id: { [Op.ne]: id },
},
});
if (existingService) {
return res.status(400).json({
status: 'error',
message: 'Service name already exists',
});
}
}
const updateData = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (price !== undefined) updateData.price = price;
if (unit !== undefined) updateData.unit = unit;
if (status !== undefined) updateData.status = status;
await service.update(updateData);
res.status(200).json({
status: 'success',
message: 'Service updated successfully',
data: {
service,
},
});
} catch (error) {
next(error);
}
};
/**
* Delete service
* DELETE /api/services/:id
*/
const deleteService = async (req, res, next) => {
try {
const { id } = req.params;
const service = await Service.findByPk(id);
if (!service) {
return res.status(404).json({
status: 'error',
message: 'Service not found',
});
}
// Check if service is used in any active bookings
const activeUsage = await BookingService.count({
where: { service_id: id },
include: [
{
model: Booking,
as: 'booking',
where: {
status: { [Op.in]: ['pending', 'confirmed', 'checked_in'] },
},
},
],
});
if (activeUsage > 0) {
return res.status(400).json({
status: 'error',
message: 'Cannot delete service that is used in active bookings',
});
}
await service.destroy();
res.status(200).json({
status: 'success',
message: 'Service deleted successfully',
});
} catch (error) {
next(error);
}
};
/**
* Add service to booking
* POST /api/services/use
*/
const useService = async (req, res, next) => {
try {
const {
booking_id,
service_id,
quantity = 1,
} = req.body;
// Check if booking exists
const booking = await Booking.findByPk(booking_id);
if (!booking) {
return res.status(404).json({
status: 'error',
message: 'Booking not found',
});
}
// Check if service exists
const service = await Service.findByPk(service_id);
if (!service || service.status !== 'active') {
return res.status(404).json({
status: 'error',
message: 'Service not found or inactive',
});
}
// Calculate total price
const total_price = service.price * quantity;
// Add service to booking
const bookingService = await BookingService.create({
booking_id,
service_id,
quantity,
price: service.price,
total_price,
});
res.status(201).json({
status: 'success',
message: 'Service added to booking successfully',
data: {
bookingService,
},
});
} catch (error) {
next(error);
}
};
module.exports = {
getServices,
getServiceById,
createService,
updateService,
deleteService,
useService,
};

View File

@@ -0,0 +1,320 @@
const { User, Booking, Role } = require('../databases/models');
const { Op } = require('sequelize');
const bcrypt = require('bcryptjs');
/**
* Get all users with filters and pagination
* GET /api/users
*/
const getUsers = async (req, res, next) => {
try {
const {
search,
role,
status,
page = 1,
limit = 10,
} = req.query;
const whereClause = {};
// Filter by search (full_name or email or phone)
if (search) {
whereClause[Op.or] = [
{ full_name: { [Op.like]: `%${search}%` } },
{ email: { [Op.like]: `%${search}%` } },
{ phone: { [Op.like]: `%${search}%` } },
];
}
// Filter by role - map string to role_id
if (role) {
const roleMap = { admin: 1, staff: 2, customer: 3 };
whereClause.role_id = roleMap[role];
}
// Filter by status - map to is_active
if (status) {
whereClause.is_active = status === 'active';
}
const offset = (parseInt(page) - 1) * parseInt(limit);
const { count, rows: users } = await User.findAndCountAll({
where: whereClause,
attributes: { exclude: ['password'] },
include: [
{
model: Role,
as: 'role',
attributes: ['name'],
},
],
limit: parseInt(limit),
offset: offset,
order: [['created_at', 'DESC']],
});
// Transform users to include role string and status string
const transformedUsers = users.map((user) => {
const userJson = user.toJSON();
return {
...userJson,
role: userJson.role?.name || 'customer',
status: userJson.is_active ? 'active' : 'inactive',
phone_number: userJson.phone, // Map phone to phone_number for frontend
};
});
res.status(200).json({
status: 'success',
data: {
users: transformedUsers,
pagination: {
total: count,
page: parseInt(page),
limit: parseInt(limit),
totalPages: Math.ceil(count / parseInt(limit)),
},
},
});
} catch (error) {
next(error);
}
};
/**
* Get user by ID
* GET /api/users/:id
*/
const getUserById = async (req, res, next) => {
try {
const { id } = req.params;
const user = await User.findByPk(id, {
attributes: { exclude: ['password'] },
include: [
{
model: Role,
as: 'role',
attributes: ['name'],
},
{
model: Booking,
as: 'bookings',
limit: 5,
order: [['created_at', 'DESC']],
},
],
});
if (!user) {
return res.status(404).json({
status: 'error',
message: 'User not found',
});
}
// Transform user to include role string and status string
const userJson = user.toJSON();
const transformedUser = {
...userJson,
role: userJson.role?.name || 'customer',
status: userJson.is_active ? 'active' : 'inactive',
phone_number: userJson.phone, // Map phone to phone_number for frontend
};
res.status(200).json({
status: 'success',
data: {
user: transformedUser,
},
});
} catch (error) {
next(error);
}
};
/**
* Create new user
* POST /api/users
*/
const createUser = async (req, res, next) => {
try {
const {
email,
password,
full_name,
phone_number, // Accept phone_number from frontend
role = 'customer',
status = 'active',
} = req.body;
// Map role string to role_id
const roleMap = {
admin: 1,
staff: 2,
customer: 3,
};
const role_id = roleMap[role] || 3; // Default to customer (3)
// Check if email already exists
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
return res.status(400).json({
status: 'error',
message: 'Email already exists',
});
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user - map phone_number to phone for DB
const user = await User.create({
email,
password: hashedPassword,
full_name,
phone: phone_number, // Map to DB column 'phone'
role_id,
is_active: status === 'active',
});
// Remove password from response
const userResponse = user.toJSON();
delete userResponse.password;
res.status(201).json({
status: 'success',
message: 'User created successfully',
data: {
user: userResponse,
},
});
} catch (error) {
next(error);
}
};
/**
* Update user
* PUT /api/users/:id
*/
const updateUser = async (req, res, next) => {
try {
const { id } = req.params;
const {
full_name,
email,
phone_number, // Accept phone_number from frontend
role,
status,
password,
} = req.body;
const user = await User.findByPk(id);
if (!user) {
return res.status(404).json({
status: 'error',
message: 'User not found',
});
}
// Check if email is being changed and if it's already taken
if (email && email !== user.email) {
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
return res.status(400).json({
status: 'error',
message: 'Email already exists',
});
}
}
// Map role string to role_id
const roleMap = {
admin: 1,
staff: 2,
customer: 3,
};
// Prepare update data - map phone_number to phone
const updateData = {};
if (full_name !== undefined) updateData.full_name = full_name;
if (email !== undefined) updateData.email = email;
if (phone_number !== undefined) updateData.phone = phone_number; // Map to DB column
if (role !== undefined) updateData.role_id = roleMap[role] || 3;
if (status !== undefined) updateData.is_active = status === 'active';
// Hash password if provided
if (password) {
updateData.password = await bcrypt.hash(password, 10);
}
await user.update(updateData);
// Remove password from response
const userResponse = user.toJSON();
delete userResponse.password;
res.status(200).json({
status: 'success',
message: 'User updated successfully',
data: {
user: userResponse,
},
});
} catch (error) {
next(error);
}
};
/**
* Delete user
* DELETE /api/users/:id
*/
const deleteUser = async (req, res, next) => {
try {
const { id } = req.params;
const user = await User.findByPk(id);
if (!user) {
return res.status(404).json({
status: 'error',
message: 'User not found',
});
}
// Check if user has active bookings
const activeBookings = await Booking.count({
where: {
user_id: id,
status: { [Op.in]: ['pending', 'confirmed', 'checked_in'] },
},
});
if (activeBookings > 0) {
return res.status(400).json({
status: 'error',
message: 'Cannot delete user with active bookings',
});
}
await user.destroy();
res.status(200).json({
status: 'success',
message: 'User deleted successfully',
});
} catch (error) {
next(error);
}
};
module.exports = {
getUsers,
getUserById,
createUser,
updateUser,
deleteUser,
};

View File

@@ -0,0 +1,40 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('roles', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
name: {
type: Sequelize.STRING(50),
allowNull: false,
unique: true
},
description: {
type: Sequelize.STRING(255),
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('roles');
}
};

View File

@@ -0,0 +1,74 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
role_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'roles',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
email: {
type: Sequelize.STRING(100),
allowNull: false,
unique: true
},
password: {
type: Sequelize.STRING(255),
allowNull: false
},
full_name: {
type: Sequelize.STRING(100),
allowNull: false
},
phone: {
type: Sequelize.STRING(20),
allowNull: true
},
address: {
type: Sequelize.TEXT,
allowNull: true
},
avatar: {
type: Sequelize.STRING(255),
allowNull: true
},
is_active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
await queryInterface.addIndex('users', ['email']);
await queryInterface.addIndex('users', ['role_id']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('users');
}
};

View File

@@ -0,0 +1,46 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('refresh_tokens', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
token: {
type: Sequelize.STRING(500),
allowNull: false,
unique: true
},
expires_at: {
type: Sequelize.DATE,
allowNull: false
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
await queryInterface.addIndex('refresh_tokens', ['user_id']);
await queryInterface.addIndex('refresh_tokens', ['token']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('refresh_tokens');
}
};

View File

@@ -0,0 +1,52 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('room_types', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
name: {
type: Sequelize.STRING(100),
allowNull: false,
unique: true
},
description: {
type: Sequelize.TEXT,
allowNull: true
},
base_price: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false
},
capacity: {
type: Sequelize.INTEGER,
allowNull: false
},
amenities: {
type: Sequelize.JSON,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('room_types');
}
};

View File

@@ -0,0 +1,75 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('rooms', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
room_type_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'room_types',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
room_number: {
type: Sequelize.STRING(20),
allowNull: false,
unique: true
},
floor: {
type: Sequelize.INTEGER,
allowNull: false
},
status: {
type: Sequelize.ENUM(
'available',
'occupied',
'maintenance',
'cleaning'
),
allowNull: false,
defaultValue: 'available'
},
price: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false
},
images: {
type: Sequelize.JSON,
allowNull: true
},
description: {
type: Sequelize.TEXT,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
await queryInterface.addIndex('rooms', ['room_type_id']);
await queryInterface.addIndex('rooms', ['status']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('rooms');
}
};

View File

@@ -0,0 +1,95 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('bookings', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
booking_number: {
type: Sequelize.STRING(50),
allowNull: false,
unique: true
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
room_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'rooms',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
check_in_date: {
type: Sequelize.DATE,
allowNull: false
},
check_out_date: {
type: Sequelize.DATE,
allowNull: false
},
num_guests: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 1
},
total_price: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false
},
status: {
type: Sequelize.ENUM(
'pending',
'confirmed',
'checked_in',
'checked_out',
'cancelled'
),
allowNull: false,
defaultValue: 'pending'
},
special_requests: {
type: Sequelize.TEXT,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
await queryInterface.addIndex('bookings', ['booking_number']);
await queryInterface.addIndex('bookings', ['user_id']);
await queryInterface.addIndex('bookings', ['room_id']);
await queryInterface.addIndex('bookings', ['status']);
await queryInterface.addIndex('bookings', ['check_in_date']);
await queryInterface.addIndex('bookings', ['check_out_date']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('bookings');
}
};

View File

@@ -0,0 +1,80 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('payments', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
booking_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'bookings',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
amount: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false
},
payment_method: {
type: Sequelize.ENUM(
'cash',
'credit_card',
'debit_card',
'bank_transfer',
'e_wallet'
),
allowNull: false
},
payment_status: {
type: Sequelize.ENUM(
'pending',
'completed',
'failed',
'refunded'
),
allowNull: false,
defaultValue: 'pending'
},
transaction_id: {
type: Sequelize.STRING(100),
allowNull: true
},
payment_date: {
type: Sequelize.DATE,
allowNull: true
},
notes: {
type: Sequelize.TEXT,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
await queryInterface.addIndex('payments', ['booking_id']);
await queryInterface.addIndex('payments', ['payment_status']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('payments');
}
};

View File

@@ -0,0 +1,54 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('services', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
name: {
type: Sequelize.STRING(100),
allowNull: false
},
description: {
type: Sequelize.TEXT,
allowNull: true
},
price: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false
},
category: {
type: Sequelize.STRING(50),
allowNull: true
},
is_active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
await queryInterface.addIndex('services', ['category']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('services');
}
};

View File

@@ -0,0 +1,76 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('service_usages', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
booking_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'bookings',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
service_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'services',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
quantity: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 1
},
unit_price: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false
},
total_price: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false
},
usage_date: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
notes: {
type: Sequelize.TEXT,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
await queryInterface.addIndex('service_usages', ['booking_id']);
await queryInterface.addIndex('service_usages', ['service_id']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('service_usages');
}
};

View File

@@ -0,0 +1,85 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('promotions', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
code: {
type: Sequelize.STRING(50),
allowNull: false,
unique: true
},
name: {
type: Sequelize.STRING(100),
allowNull: false
},
description: {
type: Sequelize.TEXT,
allowNull: true
},
discount_type: {
type: Sequelize.ENUM('percentage', 'fixed_amount'),
allowNull: false
},
discount_value: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false
},
min_booking_amount: {
type: Sequelize.DECIMAL(10, 2),
allowNull: true
},
max_discount_amount: {
type: Sequelize.DECIMAL(10, 2),
allowNull: true
},
start_date: {
type: Sequelize.DATE,
allowNull: false
},
end_date: {
type: Sequelize.DATE,
allowNull: false
},
usage_limit: {
type: Sequelize.INTEGER,
allowNull: true
},
used_count: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
is_active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
await queryInterface.addIndex('promotions', ['code']);
await queryInterface.addIndex('promotions', ['is_active']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('promotions');
}
};

View File

@@ -0,0 +1,88 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('checkin_checkout', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
booking_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'bookings',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
checkin_time: {
type: Sequelize.DATE,
allowNull: true
},
checkout_time: {
type: Sequelize.DATE,
allowNull: true
},
checkin_by: {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
checkout_by: {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
room_condition_checkin: {
type: Sequelize.TEXT,
allowNull: true
},
room_condition_checkout: {
type: Sequelize.TEXT,
allowNull: true
},
additional_charges: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0
},
notes: {
type: Sequelize.TEXT,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
await queryInterface.addIndex('checkin_checkout', ['booking_id']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('checkin_checkout');
}
};

View File

@@ -0,0 +1,73 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('banners', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
title: {
type: Sequelize.STRING(100),
allowNull: false
},
description: {
type: Sequelize.TEXT,
allowNull: true
},
image_url: {
type: Sequelize.STRING(255),
allowNull: false
},
link_url: {
type: Sequelize.STRING(255),
allowNull: true
},
position: {
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: 'home'
},
display_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
is_active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
},
start_date: {
type: Sequelize.DATE,
allowNull: true
},
end_date: {
type: Sequelize.DATE,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
});
await queryInterface.addIndex('banners', ['position']);
await queryInterface.addIndex('banners', ['is_active']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('banners');
}
};

View File

@@ -0,0 +1,59 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('password_reset_tokens', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
token: {
type: Sequelize.STRING(255),
allowNull: false,
unique: true
},
expires_at: {
type: Sequelize.DATE,
allowNull: false
},
used: {
type: Sequelize.BOOLEAN,
defaultValue: false
},
created_at: {
allowNull: false,
type: Sequelize.DATE
},
updated_at: {
allowNull: false,
type: Sequelize.DATE
}
});
await queryInterface.addIndex(
'password_reset_tokens',
['token']
);
await queryInterface.addIndex(
'password_reset_tokens',
['user_id']
);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('password_reset_tokens');
}
};

View File

@@ -0,0 +1,19 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("rooms", "featured", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
});
// add index to optimize featured queries
await queryInterface.addIndex("rooms", ["featured"]);
},
async down(queryInterface, Sequelize) {
await queryInterface.removeIndex("rooms", ["featured"]).catch(() => {});
await queryInterface.removeColumn("rooms", "featured");
}
};

View File

@@ -0,0 +1,66 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('reviews', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
room_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'rooms',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
rating: {
type: Sequelize.INTEGER,
allowNull: false
},
comment: {
type: Sequelize.TEXT,
allowNull: false
},
status: {
type: Sequelize.ENUM('pending', 'approved', 'rejected'),
allowNull: false,
defaultValue: 'pending'
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
}
});
await queryInterface.addIndex('reviews', ['room_id']);
await queryInterface.addIndex('reviews', ['user_id']);
await queryInterface.addIndex('reviews', ['status']);
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('reviews');
}
};

View File

@@ -0,0 +1,61 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('favorites', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER,
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
room_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'rooms',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
),
},
});
// Add unique constraint to prevent duplicate favorites
await queryInterface.addConstraint('favorites', {
fields: ['user_id', 'room_id'],
type: 'unique',
name: 'unique_user_room_favorite',
});
// Add index for faster queries
await queryInterface.addIndex('favorites', ['user_id']);
await queryInterface.addIndex('favorites', ['room_id']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('favorites');
},
};

View File

@@ -0,0 +1,14 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('rooms', 'amenities', {
type: Sequelize.JSON,
allowNull: true,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('rooms', 'amenities');
},
};

View File

@@ -0,0 +1,42 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// Add payment_type field to payments table
await queryInterface.addColumn('payments', 'payment_type', {
type: Sequelize.ENUM('full', 'deposit', 'remaining'),
allowNull: false,
defaultValue: 'full',
after: 'payment_method'
});
// Add deposit_percentage field
await queryInterface.addColumn('payments', 'deposit_percentage', {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: null,
comment: 'Percentage of deposit (e.g., 20, 30, 50)',
after: 'payment_type'
});
// Add reference to original payment if this is a remaining payment
await queryInterface.addColumn('payments', 'related_payment_id', {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'payments',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
after: 'booking_id'
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('payments', 'related_payment_id');
await queryInterface.removeColumn('payments', 'deposit_percentage');
await queryInterface.removeColumn('payments', 'payment_type');
}
};

View File

@@ -0,0 +1,26 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// Add deposit tracking fields to bookings table
await queryInterface.addColumn('bookings', 'deposit_paid', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
after: 'status'
});
await queryInterface.addColumn('bookings', 'requires_deposit', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
after: 'deposit_paid'
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('bookings', 'requires_deposit');
await queryInterface.removeColumn('bookings', 'deposit_paid');
}
};

View File

@@ -0,0 +1,84 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Banner extends Model {
static associate(models) {
// No associations for Banner
}
isActiveNow() {
const now = new Date();
if (!this.is_active) return false;
if (!this.start_date || !this.end_date) return this.is_active;
return now >= this.start_date && now <= this.end_date;
}
}
Banner.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
title: {
type: DataTypes.STRING(100),
allowNull: false,
validate: {
notEmpty: true
}
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
image_url: {
type: DataTypes.STRING(255),
allowNull: false,
validate: {
notEmpty: true
}
},
link_url: {
type: DataTypes.STRING(255),
allowNull: true
},
position: {
type: DataTypes.STRING(50),
allowNull: false,
defaultValue: 'home'
},
display_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
},
start_date: {
type: DataTypes.DATE,
allowNull: true
},
end_date: {
type: DataTypes.DATE,
allowNull: true
}
},
{
sequelize,
modelName: 'Banner',
tableName: 'banners',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return Banner;
};

View File

@@ -0,0 +1,130 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Booking extends Model {
static associate(models) {
// Booking belongs to User
Booking.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'user'
});
// Booking belongs to Room
Booking.belongsTo(models.Room, {
foreignKey: 'room_id',
as: 'room'
});
// Booking has many Payments
Booking.hasMany(models.Payment, {
foreignKey: 'booking_id',
as: 'payments'
});
// Booking has many ServiceUsages
Booking.hasMany(models.ServiceUsage, {
foreignKey: 'booking_id',
as: 'service_usages'
});
// Booking has one CheckInCheckOut
Booking.hasOne(models.CheckInCheckOut, {
foreignKey: 'booking_id',
as: 'checkin_checkout'
});
}
}
Booking.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
booking_number: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false
},
room_id: {
type: DataTypes.INTEGER,
allowNull: false
},
check_in_date: {
type: DataTypes.DATE,
allowNull: false
},
check_out_date: {
type: DataTypes.DATE,
allowNull: false,
validate: {
isAfterCheckIn(value) {
if (value <= this.check_in_date) {
throw new Error(
'Check-out date must be after check-in date'
);
}
}
}
},
num_guests: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
validate: {
min: 1
}
},
total_price: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
validate: {
min: 0
}
},
status: {
type: DataTypes.ENUM(
'pending',
'confirmed',
'checked_in',
'checked_out',
'cancelled'
),
allowNull: false,
defaultValue: 'pending'
},
deposit_paid: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
requires_deposit: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
special_requests: {
type: DataTypes.TEXT,
allowNull: true
}
},
{
sequelize,
modelName: 'Booking',
tableName: 'bookings',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return Booking;
};

View File

@@ -0,0 +1,88 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class CheckInCheckOut extends Model {
static associate(models) {
// CheckInCheckOut belongs to Booking
CheckInCheckOut.belongsTo(models.Booking, {
foreignKey: 'booking_id',
as: 'booking'
});
// CheckInCheckOut belongs to User (staff who checked in)
CheckInCheckOut.belongsTo(models.User, {
foreignKey: 'checkin_by',
as: 'checked_in_by'
});
// CheckInCheckOut belongs to User (staff who checked out)
CheckInCheckOut.belongsTo(models.User, {
foreignKey: 'checkout_by',
as: 'checked_out_by'
});
}
}
CheckInCheckOut.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
booking_id: {
type: DataTypes.INTEGER,
allowNull: false
},
checkin_time: {
type: DataTypes.DATE,
allowNull: true
},
checkout_time: {
type: DataTypes.DATE,
allowNull: true
},
checkin_by: {
type: DataTypes.INTEGER,
allowNull: true
},
checkout_by: {
type: DataTypes.INTEGER,
allowNull: true
},
room_condition_checkin: {
type: DataTypes.TEXT,
allowNull: true
},
room_condition_checkout: {
type: DataTypes.TEXT,
allowNull: true
},
additional_charges: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0,
validate: {
min: 0
}
},
notes: {
type: DataTypes.TEXT,
allowNull: true
}
},
{
sequelize,
modelName: 'CheckInCheckOut',
tableName: 'checkin_checkout',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return CheckInCheckOut;
};

View File

@@ -0,0 +1,55 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Favorite extends Model {
static associate(models) {
// Favorite belongs to User
Favorite.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'user',
});
// Favorite belongs to Room
Favorite.belongsTo(models.Room, {
foreignKey: 'room_id',
as: 'room',
});
}
}
Favorite.init(
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
},
room_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'rooms',
key: 'id',
},
},
},
{
sequelize,
modelName: 'Favorite',
tableName: 'favorites',
underscored: true,
timestamps: true,
}
);
return Favorite;
};

View File

@@ -0,0 +1,49 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class PasswordResetToken extends Model {
static associate(models) {
PasswordResetToken.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'user'
});
}
}
PasswordResetToken.init(
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false
},
token: {
type: DataTypes.STRING,
allowNull: false,
unique: true
},
expires_at: {
type: DataTypes.DATE,
allowNull: false
},
used: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
},
{
sequelize,
modelName: 'PasswordResetToken',
tableName: 'password_reset_tokens',
underscored: true,
timestamps: true
}
);
return PasswordResetToken;
};

View File

@@ -0,0 +1,106 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Payment extends Model {
static associate(models) {
// Payment belongs to Booking
Payment.belongsTo(models.Booking, {
foreignKey: 'booking_id',
as: 'booking'
});
// Self-referencing for deposit/remaining payment relationship
Payment.belongsTo(models.Payment, {
foreignKey: 'related_payment_id',
as: 'related_payment'
});
Payment.hasMany(models.Payment, {
foreignKey: 'related_payment_id',
as: 'related_payments'
});
}
}
Payment.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
booking_id: {
type: DataTypes.INTEGER,
allowNull: false
},
amount: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
validate: {
min: 0
}
},
payment_method: {
type: DataTypes.ENUM(
'cash',
'credit_card',
'debit_card',
'bank_transfer',
'e_wallet'
),
allowNull: false
},
payment_type: {
type: DataTypes.ENUM('full', 'deposit', 'remaining'),
allowNull: false,
defaultValue: 'full'
},
deposit_percentage: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: 0,
max: 100
}
},
related_payment_id: {
type: DataTypes.INTEGER,
allowNull: true
},
payment_status: {
type: DataTypes.ENUM(
'pending',
'completed',
'failed',
'refunded'
),
allowNull: false,
defaultValue: 'pending'
},
transaction_id: {
type: DataTypes.STRING(100),
allowNull: true
},
payment_date: {
type: DataTypes.DATE,
allowNull: true
},
notes: {
type: DataTypes.TEXT,
allowNull: true
}
},
{
sequelize,
modelName: 'Payment',
tableName: 'payments',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return Payment;
};

View File

@@ -0,0 +1,151 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Promotion extends Model {
static associate(models) {
// Associations can be defined here if needed
}
isValid() {
const now = new Date();
return (
this.is_active &&
now >= this.start_date &&
now <= this.end_date &&
(this.usage_limit === null ||
this.used_count < this.usage_limit)
);
}
calculateDiscount(bookingAmount) {
if (!this.isValid()) return 0;
if (
this.min_booking_amount &&
bookingAmount < this.min_booking_amount
) {
return 0;
}
let discount = 0;
if (this.discount_type === 'percentage') {
discount = (bookingAmount * this.discount_value) / 100;
} else if (this.discount_type === 'fixed_amount') {
discount = parseFloat(this.discount_value);
}
if (
this.max_discount_amount &&
discount > this.max_discount_amount
) {
discount = parseFloat(this.max_discount_amount);
}
return discount;
}
}
Promotion.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
validate: {
notEmpty: true
}
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
validate: {
notEmpty: true
}
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
discount_type: {
type: DataTypes.ENUM('percentage', 'fixed_amount'),
allowNull: false
},
discount_value: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
validate: {
min: 0
}
},
min_booking_amount: {
type: DataTypes.DECIMAL(10, 2),
allowNull: true,
validate: {
min: 0
}
},
max_discount_amount: {
type: DataTypes.DECIMAL(10, 2),
allowNull: true,
validate: {
min: 0
}
},
start_date: {
type: DataTypes.DATE,
allowNull: false
},
end_date: {
type: DataTypes.DATE,
allowNull: false,
validate: {
isAfterStartDate(value) {
if (value <= this.start_date) {
throw new Error(
'End date must be after start date'
);
}
}
}
},
usage_limit: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: 1
}
},
used_count: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: 0
}
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
}
},
{
sequelize,
modelName: 'Promotion',
tableName: 'promotions',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return Promotion;
};

View File

@@ -0,0 +1,49 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class RefreshToken extends Model {
static associate(models) {
// RefreshToken belongs to User
RefreshToken.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'user'
});
}
}
RefreshToken.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false
},
token: {
type: DataTypes.STRING(500),
allowNull: false,
unique: true
},
expires_at: {
type: DataTypes.DATE,
allowNull: false
}
},
{
sequelize,
modelName: 'RefreshToken',
tableName: 'refresh_tokens',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: false
}
);
return RefreshToken;
};

View File

@@ -0,0 +1,61 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Review extends Model {
static associate(models) {
Review.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'user'
});
Review.belongsTo(models.Room, {
foreignKey: 'room_id',
as: 'room'
});
}
}
Review.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false
},
room_id: {
type: DataTypes.INTEGER,
allowNull: false
},
rating: {
type: DataTypes.INTEGER,
allowNull: false
},
comment: {
type: DataTypes.TEXT,
allowNull: false
},
status: {
type: DataTypes.ENUM('pending', 'approved', 'rejected'),
allowNull: false,
defaultValue: 'pending'
}
},
{
sequelize,
modelName: 'Review',
tableName: 'reviews',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return Review;
};

View File

@@ -0,0 +1,49 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Role extends Model {
static associate(models) {
// Role has many Users
Role.hasMany(models.User, {
foreignKey: 'role_id',
as: 'users'
});
}
}
Role.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
name: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
validate: {
notEmpty: true,
isIn: [['admin', 'staff', 'customer']]
}
},
description: {
type: DataTypes.STRING(255),
allowNull: true
}
},
{
sequelize,
modelName: 'Role',
tableName: 'roles',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return Role;
};

View File

@@ -0,0 +1,107 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Room extends Model {
static associate(models) {
// Room belongs to RoomType
Room.belongsTo(models.RoomType, {
foreignKey: 'room_type_id',
as: 'room_type'
});
// Room has many Bookings
Room.hasMany(models.Booking, {
foreignKey: 'room_id',
as: 'bookings'
});
// Room has many Reviews
Room.hasMany(models.Review, {
foreignKey: 'room_id',
as: 'reviews'
});
// Room has many Favorites
Room.hasMany(models.Favorite, {
foreignKey: 'room_id',
as: 'favorites'
});
}
}
Room.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
room_type_id: {
type: DataTypes.INTEGER,
allowNull: false
},
room_number: {
type: DataTypes.STRING(20),
allowNull: false,
unique: true,
validate: {
notEmpty: true
}
},
floor: {
type: DataTypes.INTEGER,
allowNull: false,
validate: {
min: 1
}
},
status: {
type: DataTypes.ENUM(
'available',
'occupied',
'maintenance',
'cleaning'
),
allowNull: false,
defaultValue: 'available'
},
price: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
validate: {
min: 0
}
},
featured: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
images: {
type: DataTypes.JSON,
allowNull: true
},
amenities: {
type: DataTypes.JSON,
allowNull: true
},
description: {
type: DataTypes.TEXT,
allowNull: true
}
},
{
sequelize,
modelName: 'Room',
tableName: 'rooms',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return Room;
};

View File

@@ -0,0 +1,66 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class RoomType extends Model {
static associate(models) {
// RoomType has many Rooms
RoomType.hasMany(models.Room, {
foreignKey: 'room_type_id',
as: 'rooms'
});
}
}
RoomType.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
unique: true,
validate: {
notEmpty: true
}
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
base_price: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
validate: {
min: 0
}
},
capacity: {
type: DataTypes.INTEGER,
allowNull: false,
validate: {
min: 1
}
},
amenities: {
type: DataTypes.JSON,
allowNull: true
}
},
{
sequelize,
modelName: 'RoomType',
tableName: 'room_types',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return RoomType;
};

View File

@@ -0,0 +1,63 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Service extends Model {
static associate(models) {
// Service has many ServiceUsages
Service.hasMany(models.ServiceUsage, {
foreignKey: 'service_id',
as: 'service_usages'
});
}
}
Service.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
validate: {
notEmpty: true
}
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
price: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
validate: {
min: 0
}
},
category: {
type: DataTypes.STRING(50),
allowNull: true
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
}
},
{
sequelize,
modelName: 'Service',
tableName: 'services',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return Service;
};

View File

@@ -0,0 +1,81 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class ServiceUsage extends Model {
static associate(models) {
// ServiceUsage belongs to Booking
ServiceUsage.belongsTo(models.Booking, {
foreignKey: 'booking_id',
as: 'booking'
});
// ServiceUsage belongs to Service
ServiceUsage.belongsTo(models.Service, {
foreignKey: 'service_id',
as: 'service'
});
}
}
ServiceUsage.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
booking_id: {
type: DataTypes.INTEGER,
allowNull: false
},
service_id: {
type: DataTypes.INTEGER,
allowNull: false
},
quantity: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
validate: {
min: 1
}
},
unit_price: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
validate: {
min: 0
}
},
total_price: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
validate: {
min: 0
}
},
usage_date: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
},
notes: {
type: DataTypes.TEXT,
allowNull: true
}
},
{
sequelize,
modelName: 'ServiceUsage',
tableName: 'service_usages',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return ServiceUsage;
};

View File

@@ -0,0 +1,121 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
static associate(models) {
// User belongs to Role
User.belongsTo(models.Role, {
foreignKey: 'role_id',
as: 'role'
});
// User has many Bookings
User.hasMany(models.Booking, {
foreignKey: 'user_id',
as: 'bookings'
});
// User has many RefreshTokens
User.hasMany(models.RefreshToken, {
foreignKey: 'user_id',
as: 'refresh_tokens'
});
// User has many CheckInCheckOut records as staff
User.hasMany(models.CheckInCheckOut, {
foreignKey: 'checkin_by',
as: 'checkins_processed'
});
User.hasMany(models.CheckInCheckOut, {
foreignKey: 'checkout_by',
as: 'checkouts_processed'
});
// User has many Reviews
User.hasMany(models.Review, {
foreignKey: 'user_id',
as: 'reviews'
});
// User has many Favorites
User.hasMany(models.Favorite, {
foreignKey: 'user_id',
as: 'favorites'
});
}
toJSON() {
const values = { ...this.get() };
delete values.password;
return values;
}
}
User.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
role_id: {
type: DataTypes.INTEGER,
allowNull: false
},
email: {
type: DataTypes.STRING(100),
allowNull: false,
unique: true,
validate: {
isEmail: true,
notEmpty: true
}
},
password: {
type: DataTypes.STRING(255),
allowNull: false,
validate: {
notEmpty: true
}
},
full_name: {
type: DataTypes.STRING(100),
allowNull: false,
validate: {
notEmpty: true
}
},
phone: {
type: DataTypes.STRING(20),
allowNull: true
},
address: {
type: DataTypes.TEXT,
allowNull: true
},
avatar: {
type: DataTypes.STRING(255),
allowNull: true
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
}
},
{
sequelize,
modelName: 'User',
tableName: 'users',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
);
return User;
};

View File

@@ -0,0 +1,53 @@
'use strict';
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const process = require('process');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../../config/database.js')[env];
const db = {};
let sequelize;
if (config.use_env_variable) {
sequelize = new Sequelize(
process.env[config.use_env_variable],
config
);
} else {
sequelize = new Sequelize(
config.database,
config.username,
config.password,
config
);
}
fs.readdirSync(__dirname)
.filter((file) => {
return (
file.indexOf('.') !== 0 &&
file !== basename &&
file.slice(-3) === '.js' &&
file.indexOf('.test.js') === -1
);
})
.forEach((file) => {
const model = require(path.join(__dirname, file))(
sequelize,
Sequelize.DataTypes
);
db[model.name] = model;
});
Object.keys(db).forEach((modelName) => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;

View File

@@ -0,0 +1,35 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.bulkInsert('roles', [
{
id: 1,
name: 'admin',
description: 'Administrator with full system access',
created_at: new Date(),
updated_at: new Date()
},
{
id: 2,
name: 'staff',
description:
'Staff member handling bookings and operations',
created_at: new Date(),
updated_at: new Date()
},
{
id: 3,
name: 'customer',
description: 'Customer who can book rooms',
created_at: new Date(),
updated_at: new Date()
}
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('roles', null, {});
}
};

View File

@@ -0,0 +1,95 @@
'use strict';
const bcrypt = require('bcrypt');
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
const hashedPassword = await bcrypt.hash('password123', 10);
await queryInterface.bulkInsert('users', [
{
id: 1,
role_id: 1,
email: 'admin@hotel.com',
password: hashedPassword,
full_name: 'Admin User',
phone: '0901234567',
address: '123 Admin Street, District 1, HCMC',
avatar: null,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 2,
role_id: 2,
email: 'staff@hotel.com',
password: hashedPassword,
full_name: 'Staff Member',
phone: '0902345678',
address: '456 Staff Avenue, District 3, HCMC',
avatar: null,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 3,
role_id: 2,
email: 'staff2@hotel.com',
password: hashedPassword,
full_name: 'Staff Member 2',
phone: '0903456789',
address: '789 Staff Road, District 5, HCMC',
avatar: null,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 4,
role_id: 3,
email: 'customer1@gmail.com',
password: hashedPassword,
full_name: 'Nguyen Van A',
phone: '0904567890',
address: '111 Customer Street, District 7, HCMC',
avatar: null,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 5,
role_id: 3,
email: 'customer2@gmail.com',
password: hashedPassword,
full_name: 'Tran Thi B',
phone: '0905678901',
address: '222 Customer Avenue, District 10, HCMC',
avatar: null,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 6,
role_id: 3,
email: 'customer3@gmail.com',
password: hashedPassword,
full_name: 'Le Van C',
phone: '0906789012',
address: '333 Customer Road, Binh Thanh, HCMC',
avatar: null,
is_active: true,
created_at: new Date(),
updated_at: new Date()
}
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('users', null, {});
}
};

View File

@@ -0,0 +1,95 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
// master amenities pool
const masterAmenities = [
'King Bed',
'Double Bed',
'Air Conditioning',
'WiFi',
'Smart TV',
'Private Bathroom',
'Mini Fridge',
'Work Desk',
'Safe',
'Balcony',
'Coffee Maker',
'Bathtub',
];
const pick = (pool, min, max) => {
const count = Math.floor(Math.random() * (max - min + 1)) + min;
const copy = [...pool];
const out = [];
for (let i = 0; i < count && copy.length; i++) {
const idx = Math.floor(Math.random() * copy.length);
out.push(copy.splice(idx, 1)[0]);
}
return out;
};
await queryInterface.bulkInsert('room_types', [
{
id: 1,
name: 'Standard Room',
description:
'Cozy single room with basic amenities, perfect for solo travelers',
base_price: 500000,
capacity: 1,
amenities: JSON.stringify(pick(masterAmenities, 4, 6)),
created_at: new Date(),
updated_at: new Date(),
},
{
id: 2,
name: 'Twin Room',
description:
'Comfortable double room with modern amenities, perfect for couples or friends',
base_price: 800000,
capacity: 2,
amenities: JSON.stringify(pick(masterAmenities, 5, 7)),
created_at: new Date(),
updated_at: new Date(),
},
{
id: 3,
name: 'Deluxe Room',
description:
'Spacious deluxe room with premium amenities and city view',
base_price: 1200000,
capacity: 2,
amenities: JSON.stringify(pick(masterAmenities, 6, 9)),
created_at: new Date(),
updated_at: new Date(),
},
{
id: 4,
name: 'Family Room',
description:
'Spacious family room with separate living area and multiple beds, perfect for families',
base_price: 2000000,
capacity: 4,
amenities: JSON.stringify(pick(masterAmenities, 7, 10)),
created_at: new Date(),
updated_at: new Date(),
},
{
id: 5,
name: 'Luxury Room',
description:
'Luxurious suite with panoramic views and premium services',
base_price: 5000000,
capacity: 4,
amenities: JSON.stringify(pick(masterAmenities, 8, 11)),
created_at: new Date(),
updated_at: new Date(),
},
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('room_types', null, {});
}
};

View File

@@ -0,0 +1,173 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
const rooms = [];
// Standard Single Rooms (Floor 1-2)
for (let floor = 1; floor <= 2; floor++) {
for (let room = 1; room <= 5; room++) {
rooms.push({
room_type_id: 1,
room_number: `${floor}0${room}`,
floor: floor,
status: 'available',
featured: false,
price: 500000,
images: JSON.stringify([
'/uploads/rooms/standard-single-1.png',
'/uploads/rooms/standard-single-2.png'
]),
description:
`Standard single room on floor ${floor}`,
created_at: new Date(),
updated_at: new Date()
});
}
}
// Standard Double Rooms (Floor 3-4)
for (let floor = 3; floor <= 4; floor++) {
for (let room = 1; room <= 8; room++) {
rooms.push({
room_type_id: 2,
room_number: `${floor}0${room}`,
floor: floor,
status: 'available',
featured: false,
price: 800000,
images: JSON.stringify([
'/uploads/rooms/standard-double-1.png',
'/uploads/rooms/standard-double-2.png',
'/uploads/rooms/standard-double-3.png'
]),
description:
`Standard double room on floor ${floor}`,
created_at: new Date(),
updated_at: new Date()
});
}
}
// Deluxe Rooms (Floor 5-7)
for (let floor = 5; floor <= 7; floor++) {
for (let room = 1; room <= 6; room++) {
rooms.push({
room_type_id: 3,
room_number: `${floor}0${room}`,
floor: floor,
status: 'available',
featured: false,
price: 1200000,
images: JSON.stringify([
'/uploads/rooms/deluxe-1.png',
'/uploads/rooms/deluxe-2.png',
'/uploads/rooms/deluxe-3.png',
'/uploads/rooms/deluxe-4.png'
]),
description:
`Deluxe room on floor ${floor} with city view`,
created_at: new Date(),
updated_at: new Date()
});
}
}
// Family Suites (Floor 8-9)
for (let floor = 8; floor <= 9; floor++) {
for (let room = 1; room <= 4; room++) {
rooms.push({
room_type_id: 4,
room_number: `${floor}0${room}`,
floor: floor,
status: 'available',
featured: false,
price: 2000000,
images: JSON.stringify([
'/uploads/rooms/family-suite-1.png',
'/uploads/rooms/family-suite-2.png',
'/uploads/rooms/family-suite-3.png',
'/uploads/rooms/family-suite-4.png',
'/uploads/rooms/family-suite-5.png'
]),
description:
`Family suite on floor ${floor}`,
created_at: new Date(),
updated_at: new Date()
});
}
}
// Presidential Suites (Floor 10)
for (let room = 1; room <= 2; room++) {
rooms.push({
room_type_id: 5,
room_number: `100${room}`,
floor: 10,
status: 'available',
featured: false,
price: 5000000,
images: JSON.stringify([
'/uploads/rooms/presidential-1.png',
'/uploads/rooms/presidential-2.png',
'/uploads/rooms/presidential-3.png',
'/uploads/rooms/presidential-4.png',
'/uploads/rooms/presidential-5.png',
'/uploads/rooms/presidential-6.png'
]),
description:
'Presidential suite with panoramic city view',
created_at: new Date(),
updated_at: new Date()
});
}
// Mark some rooms as occupied for realism
rooms[0].status = 'occupied';
rooms[5].status = 'occupied';
rooms[12].status = 'cleaning';
rooms[20].status = 'maintenance';
// Ensure there are always 10 featured rooms in the seed data.
// Strategy: prefer higher-tier room types first (presidential ->
// family -> deluxe -> double -> single) and mark rooms until
// we reach `desiredFeaturedCount`.
const desiredFeaturedCount = 10;
let featuredMarked = 0;
// Helper: mark room at index if exists and not already featured
const markIf = (idx) => {
if (idx >= 0 && idx < rooms.length && !rooms[idx].featured) {
rooms[idx].featured = true;
featuredMarked += 1;
}
};
// Priority order by room_type_id (higher-tier first)
const priority = [5, 4, 3, 2, 1];
for (const typeId of priority) {
if (featuredMarked >= desiredFeaturedCount) break;
// iterate rooms and pick the first ones of this type
for (let i = 0; i < rooms.length; i++) {
if (rooms[i].room_type_id === typeId) {
markIf(i);
if (featuredMarked >= desiredFeaturedCount) break;
}
}
}
// If still not enough, mark remaining rooms starting from end
for (let i = rooms.length - 1; i >= 0 && featuredMarked < desiredFeaturedCount; i--) {
markIf(i);
}
await queryInterface.bulkInsert('rooms', rooms);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('rooms', null, {});
}
};

View File

@@ -0,0 +1,137 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.bulkInsert('services', [
// Food & Beverage
{
id: 1,
name: 'Room Service - Breakfast',
description: 'Continental breakfast delivered to room',
price: 150000,
category: 'Food & Beverage',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 2,
name: 'Room Service - Lunch',
description: 'Lunch menu delivered to room',
price: 250000,
category: 'Food & Beverage',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 3,
name: 'Room Service - Dinner',
description: 'Dinner menu delivered to room',
price: 300000,
category: 'Food & Beverage',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
// Laundry
{
id: 4,
name: 'Laundry Service - Express',
description: 'Same-day laundry service (per kg)',
price: 100000,
category: 'Laundry',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 5,
name: 'Laundry Service - Standard',
description: 'Next-day laundry service (per kg)',
price: 60000,
category: 'Laundry',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 6,
name: 'Dry Cleaning',
description: 'Professional dry cleaning service',
price: 80000,
category: 'Laundry',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
// Spa & Wellness
{
id: 7,
name: 'Spa - Traditional Massage',
description: 'Traditional 60-minute massage',
price: 500000,
category: 'Spa & Wellness',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
// Transportation
{
id: 8,
name: 'Airport Pickup',
description: 'Private car from airport to hotel',
price: 400000,
category: 'Transportation',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 9,
name: 'Airport Drop-off',
description: 'Private car from hotel to airport',
price: 400000,
category: 'Transportation',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 10,
name: 'City Tour - Half Day',
description: 'Half-day guided city tour',
price: 800000,
category: 'Transportation',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 11,
name: 'Late Check-out',
description: 'Late check-out until 18:00',
price: 500000,
category: 'Room Amenities',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 12,
name: 'Early Check-in',
description: 'Early check-in from 06:00',
price: 500000,
category: 'Room Amenities',
is_active: true,
created_at: new Date(),
updated_at: new Date()
}
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('services', null, {});
}
};

View File

@@ -0,0 +1,140 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
const now = new Date();
const futureDate = new Date();
futureDate.setMonth(futureDate.getMonth() + 6);
await queryInterface.bulkInsert('promotions', [
{
id: 1,
code: 'WELCOME2025',
name: 'Welcome 2025',
description:
'20% discount for new customers on first booking',
discount_type: 'percentage',
discount_value: 20,
min_booking_amount: 1000000,
max_discount_amount: 500000,
start_date: now,
end_date: futureDate,
usage_limit: 100,
used_count: 15,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 2,
code: 'SUMMER2025',
name: 'Summer Special',
description:
'Fixed 300,000 VND discount for summer bookings',
discount_type: 'fixed_amount',
discount_value: 300000,
min_booking_amount: 2000000,
max_discount_amount: null,
start_date: now,
end_date: futureDate,
usage_limit: 50,
used_count: 8,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 3,
code: 'WEEKEND15',
name: 'Weekend Getaway',
description: '15% off for weekend bookings',
discount_type: 'percentage',
discount_value: 15,
min_booking_amount: 800000,
max_discount_amount: 400000,
start_date: now,
end_date: futureDate,
usage_limit: null,
used_count: 25,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 4,
code: 'FAMILY500',
name: 'Family Package',
description:
'500,000 VND off for family suite bookings',
discount_type: 'fixed_amount',
discount_value: 500000,
min_booking_amount: 3000000,
max_discount_amount: null,
start_date: now,
end_date: futureDate,
usage_limit: 30,
used_count: 5,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 5,
code: 'LONGSTAY25',
name: 'Long Stay Discount',
description: '25% off for bookings 5 nights or more',
discount_type: 'percentage',
discount_value: 25,
min_booking_amount: 5000000,
max_discount_amount: 2000000,
start_date: now,
end_date: futureDate,
usage_limit: null,
used_count: 3,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 6,
code: 'EARLYBIRD10',
name: 'Early Bird Special',
description:
'10% discount for bookings 30 days in advance',
discount_type: 'percentage',
discount_value: 10,
min_booking_amount: 500000,
max_discount_amount: 300000,
start_date: now,
end_date: futureDate,
usage_limit: null,
used_count: 42,
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
{
id: 7,
code: 'VIP1000',
name: 'VIP Member Exclusive',
description: '1,000,000 VND off for VIP members',
discount_type: 'fixed_amount',
discount_value: 1000000,
min_booking_amount: 10000000,
max_discount_amount: null,
start_date: now,
end_date: futureDate,
usage_limit: 10,
used_count: 1,
is_active: true,
created_at: new Date(),
updated_at: new Date()
}
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('promotions', null, {});
}
};

View File

@@ -0,0 +1,94 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
const now = new Date();
const futureDate = new Date();
futureDate.setMonth(futureDate.getMonth() + 6);
await queryInterface.bulkInsert('banners', [
{
id: 1,
title: 'Welcome to Paradise Hotel',
description:
'Experience luxury and comfort in the heart of the city',
image_url: '/uploads/banners/banner-1.png',
link_url: '/rooms',
position: 'home',
display_order: 1,
is_active: true,
start_date: now,
end_date: futureDate,
created_at: new Date(),
updated_at: new Date()
},
{
id: 2,
title: 'Located Near City Center',
description:
'Book now and save big on your summer vacation',
image_url: '/uploads/banners/banner-2.png',
link_url: '/promotions',
position: 'home',
display_order: 2,
is_active: true,
start_date: now,
end_date: futureDate,
created_at: new Date(),
updated_at: new Date()
},
{
id: 3,
title: 'Summer Promotion - Up to 30% Off',
description:
'Indulge in ultimate luxury with our ' +
'presidential suite',
image_url: '/uploads/banners/banner-3.png',
link_url: '/rooms/presidential-suite',
position: 'home',
display_order: 3,
is_active: true,
start_date: now,
end_date: futureDate,
created_at: new Date(),
updated_at: new Date()
},
{
id: 4,
title: 'Presidential Suite',
description:
'Relax and rejuvenate at our world-class spa',
image_url: '/uploads/banners/banner-4.png',
link_url: '/services#spa',
position: 'home',
display_order: 4,
is_active: true,
start_date: now,
end_date: futureDate,
created_at: new Date(),
updated_at: new Date()
},
{
id: 5,
title: 'Family Package Deal',
description:
'Perfect getaway for the whole family with ' +
'special rates',
image_url: '/uploads/banners/banner-5.png',
link_url: '/rooms?type=family',
position: 'home',
display_order: 5,
is_active: true,
start_date: now,
end_date: futureDate,
created_at: new Date(),
updated_at: new Date()
},
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('banners', null, {});
}
};

View File

@@ -0,0 +1,124 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const nextWeek = new Date(now);
nextWeek.setDate(nextWeek.getDate() + 7);
const inTwoDays = new Date(now);
inTwoDays.setDate(inTwoDays.getDate() + 2);
const inFiveDays = new Date(now);
inFiveDays.setDate(inFiveDays.getDate() + 5);
// Query existing room ids to avoid FK issues if ids differ
const [rooms] = await queryInterface.sequelize.query(
'SELECT id FROM rooms ORDER BY id ASC'
);
if (!rooms || rooms.length === 0) {
throw new Error('No rooms present. Run room seeders before bookings.');
}
const pickRoom = (idx) => rooms[idx % rooms.length].id;
await queryInterface.bulkInsert('bookings', [
// Past booking (checked out)
{
booking_number: 'BK2025010001',
user_id: 4,
room_id: pickRoom(0),
check_in_date: new Date('2025-01-15T14:00:00'),
check_out_date: new Date('2025-01-18T12:00:00'),
num_guests: 1,
total_price: 1500000,
status: 'checked_out',
special_requests: null,
created_at: new Date('2025-01-10'),
updated_at: new Date('2025-01-18'),
},
// Current booking (checked in)
{
booking_number: 'BK2025010002',
user_id: 5,
room_id: pickRoom(1),
check_in_date: yesterday,
check_out_date: inTwoDays,
num_guests: 2,
total_price: 2400000,
status: 'checked_in',
special_requests: 'Late check-out if possible',
created_at: new Date('2025-01-20'),
updated_at: now,
},
// Upcoming confirmed booking
{
booking_number: 'BK2025010003',
user_id: 6,
room_id: pickRoom(2),
check_in_date: inTwoDays,
check_out_date: inFiveDays,
num_guests: 2,
total_price: 3600000,
status: 'confirmed',
special_requests: 'High floor room with city view',
created_at: new Date('2025-01-22'),
updated_at: new Date('2025-01-22'),
},
// Pending booking
{
booking_number: 'BK2025010004',
user_id: 4,
room_id: pickRoom(3),
check_in_date: tomorrow,
check_out_date: inFiveDays,
num_guests: 4,
total_price: 8000000,
status: 'pending',
special_requests: 'Need baby cot and extra pillows',
created_at: now,
updated_at: now,
},
// Upcoming booking for next week
{
booking_number: 'BK2025010005',
user_id: 5,
room_id: pickRoom(4),
check_in_date: nextWeek,
check_out_date: new Date(nextWeek.getTime() + 3 * 24 * 60 * 60 * 1000),
num_guests: 2,
total_price: 15000000,
status: 'confirmed',
special_requests: 'Champagne and flowers for anniversary',
created_at: new Date('2025-01-25'),
updated_at: new Date('2025-01-25'),
},
// Cancelled booking
{
booking_number: 'BK2025010006',
user_id: 6,
room_id: pickRoom(5),
check_in_date: new Date('2025-02-10T14:00:00'),
check_out_date: new Date('2025-02-12T12:00:00'),
num_guests: 2,
total_price: 1600000,
status: 'cancelled',
special_requests: null,
created_at: new Date('2025-01-20'),
updated_at: new Date('2025-01-28'),
},
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('bookings', null, {});
}
};

View File

@@ -0,0 +1,86 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.bulkInsert('payments', [
{
id: 1,
booking_id: 1,
amount: 1500000,
payment_method: 'credit_card',
payment_status: 'completed',
transaction_id: 'TXN2025011501234',
payment_date: new Date('2025-01-15T10:30:00'),
notes: 'Paid in full at check-in',
created_at: new Date('2025-01-15'),
updated_at: new Date('2025-01-15')
},
{
id: 2,
booking_id: 2,
amount: 2400000,
payment_method: 'bank_transfer',
payment_status: 'completed',
transaction_id: 'TXN2025012801567',
payment_date: new Date('2025-01-28T09:15:00'),
notes: 'Bank transfer confirmed',
created_at: new Date('2025-01-28'),
updated_at: new Date('2025-01-28')
},
{
id: 3,
booking_id: 3,
amount: 1800000,
payment_method: 'e_wallet',
payment_status: 'completed',
transaction_id: 'TXN2025012201890',
payment_date: new Date('2025-01-22T14:20:00'),
notes: '50% deposit paid',
created_at: new Date('2025-01-22'),
updated_at: new Date('2025-01-22')
},
{
id: 4,
booking_id: 4,
amount: 8000000,
payment_method: 'credit_card',
payment_status: 'pending',
transaction_id: null,
payment_date: null,
notes: 'Awaiting payment confirmation',
created_at: new Date(),
updated_at: new Date()
},
{
id: 5,
booking_id: 5,
amount: 7500000,
payment_method: 'bank_transfer',
payment_status: 'completed',
transaction_id: 'TXN2025012502345',
payment_date: new Date('2025-01-25T11:45:00'),
notes: '50% deposit paid, balance due at check-in',
created_at: new Date('2025-01-25'),
updated_at: new Date('2025-01-25')
},
{
id: 6,
booking_id: 6,
amount: 1600000,
payment_method: 'credit_card',
payment_status: 'refunded',
transaction_id: 'TXN2025012003456',
payment_date: new Date('2025-01-20T16:00:00'),
notes:
'Booking cancelled, full refund processed',
created_at: new Date('2025-01-20'),
updated_at: new Date('2025-01-28')
}
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('payments', null, {});
}
};

View File

@@ -0,0 +1,153 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
// We'll resolve booking ids and service ids at runtime to avoid FK
// issues regardless of auto-increment / insertion order.
const usages = [
// booking_number, service_name, rest of fields
{
booking_number: 'BK2025010001',
service_name: 'Dịch vụ phòng - Bữa sáng',
quantity: 2,
unit_price: 150000,
total_price: 300000,
usage_date: new Date('2025-01-16T07:30:00'),
notes: 'Breakfast for 2 days',
created_at: new Date('2025-01-16'),
updated_at: new Date('2025-01-16'),
},
{
booking_number: 'BK2025010001',
service_name: 'Dịch vụ giặt ủi - Thông thường',
quantity: 3,
unit_price: 60000,
total_price: 180000,
usage_date: new Date('2025-01-17T10:00:00'),
notes: 'Regular laundry service',
created_at: new Date('2025-01-17'),
updated_at: new Date('2025-01-17'),
},
{
booking_number: 'BK2025010002',
service_name: 'Dịch vụ phòng - Bữa sáng',
quantity: 1,
unit_price: 150000,
total_price: 150000,
usage_date: new Date('2025-01-29T08:00:00'),
notes: 'Room service breakfast',
created_at: new Date('2025-01-29'),
updated_at: new Date('2025-01-29'),
},
{
booking_number: 'BK2025010002',
service_name: 'Spa - Massage truyền thống',
quantity: 1,
unit_price: 500000,
total_price: 500000,
usage_date: new Date('2025-01-29T15:00:00'),
notes: 'Traditional massage booking',
created_at: new Date('2025-01-29'),
updated_at: new Date('2025-01-29'),
},
{
booking_number: 'BK2025010002',
service_name: 'Trả phòng muộn',
quantity: 1,
unit_price: 500000,
total_price: 500000,
usage_date: new Date('2025-01-30T12:00:00'),
notes: 'Late check-out requested',
created_at: new Date('2025-01-30'),
updated_at: new Date('2025-01-30'),
},
{
booking_number: 'BK2025010003',
service_name: 'Đón sân bay',
quantity: 1,
unit_price: 400000,
total_price: 400000,
usage_date: new Date('2025-01-31'),
notes: 'Airport pickup pre-booked',
created_at: new Date('2025-01-22'),
updated_at: new Date('2025-01-22'),
},
{
booking_number: 'BK2025010005',
service_name: 'Đón sân bay',
quantity: 1,
unit_price: 400000,
total_price: 400000,
usage_date: new Date('2025-02-05'),
notes: 'Airport pickup for anniversary trip',
created_at: new Date('2025-01-25'),
updated_at: new Date('2025-01-25'),
},
{
booking_number: 'BK2025010005',
service_name: 'Spa - Liệu pháp hương thơm',
quantity: 1,
unit_price: 700000,
total_price: 700000,
usage_date: new Date('2025-02-06T16:00:00'),
notes: 'Aromatherapy session for couple',
created_at: new Date('2025-01-25'),
updated_at: new Date('2025-01-25'),
},
];
// Resolve bookings and services
const bookingNumbers = Array.from(new Set(usages.map((u) => u.booking_number)));
const serviceNames = Array.from(new Set(usages.map((u) => u.service_name)));
const [bookingsRows] = await queryInterface.sequelize.query(
`SELECT id, booking_number FROM bookings WHERE booking_number IN (${bookingNumbers
.map(() => '?')
.join(',')})`,
{ replacements: bookingNumbers }
);
const [servicesRows] = await queryInterface.sequelize.query(
`SELECT id, name FROM services WHERE name IN (${serviceNames.map(() => '?').join(',')})`,
{ replacements: serviceNames }
);
const bookingIdByNumber = {};
bookingsRows.forEach((r) => {
bookingIdByNumber[r.booking_number] = r.id;
});
const serviceIdByName = {};
servicesRows.forEach((r) => {
serviceIdByName[r.name] = r.id;
});
const records = usages
.map((u) => {
const booking_id = bookingIdByNumber[u.booking_number] || null;
const service_id = serviceIdByName[u.service_name] || null;
if (!booking_id || !service_id) return null;
return {
booking_id,
service_id,
quantity: u.quantity,
unit_price: u.unit_price,
total_price: u.total_price,
usage_date: u.usage_date,
notes: u.notes,
created_at: u.created_at,
updated_at: u.updated_at,
};
})
.filter(Boolean);
if (records.length > 0) {
await queryInterface.bulkInsert('service_usages', records);
}
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('service_usages', null, {});
}
};

View File

@@ -0,0 +1,49 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.bulkInsert('checkin_checkout', [
// Completed check-in and check-out (booking 1)
{
id: 1,
booking_id: 1,
checkin_time: new Date('2025-01-15T14:30:00'),
checkout_time: new Date('2025-01-18T11:45:00'),
checkin_by: 2,
checkout_by: 3,
room_condition_checkin:
'Room in perfect condition. All amenities checked.',
room_condition_checkout:
'Room left in good condition. Mini bar consumed ' +
'2 items (added to bill).',
additional_charges: 50000,
notes:
'Guest very satisfied. Requested early breakfast.',
created_at: new Date('2025-01-15'),
updated_at: new Date('2025-01-18')
},
// Only checked in (booking 2)
{
id: 2,
booking_id: 2,
checkin_time: new Date('2025-01-28T15:15:00'),
checkout_time: null,
checkin_by: 2,
checkout_by: null,
room_condition_checkin:
'Room prepared and checked. Welcome package placed.',
room_condition_checkout: null,
additional_charges: 0,
notes:
'Guest requested late check-out. Approved.',
created_at: new Date('2025-01-28'),
updated_at: new Date('2025-01-30')
}
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('checkin_checkout', null, {});
}
};

View File

@@ -0,0 +1,104 @@
'use strict';
/** @type {import('sequelize-cli').Seeder} */
module.exports = {
async up(queryInterface, Sequelize) {
const now = new Date();
// Fetch some existing room ids to avoid FK constraint errors.
const [rooms] = await queryInterface.sequelize.query(
'SELECT id FROM rooms ORDER BY id ASC LIMIT 20'
);
if (!rooms || rooms.length === 0) {
throw new Error(
'No rooms found in database. Please run rooms seeders before seeding reviews.'
);
}
// simple helper to pick a room id by index
const roomId = (idx) => rooms[idx % rooms.length].id;
const reviews = [
{
user_id: 4,
room_id: roomId(0),
rating: 5,
comment: 'Clean room, friendly staff. Great experience!',
status: 'approved',
created_at: now,
updated_at: now,
},
{
user_id: 5,
room_id: roomId(1),
rating: 4,
comment: 'Convenient location, small but cozy room.',
status: 'approved',
created_at: now,
updated_at: now,
},
{
user_id: 6,
room_id: roomId(2),
rating: 5,
comment: 'Beautiful view, delicious breakfast. Will return.',
status: 'approved',
created_at: now,
updated_at: now,
},
{
user_id: 4,
room_id: roomId(3),
rating: 3,
comment:
'Room a bit noisy near elevator, but amenities are complete.',
status: 'approved',
created_at: now,
updated_at: now,
},
{
user_id: 5,
room_id: roomId(4),
rating: 4,
comment: 'Good service, reasonable prices.',
status: 'approved',
created_at: now,
updated_at: now,
},
{
user_id: 6,
room_id: roomId(5),
rating: 5,
comment: 'Very spacious suite, perfect for families.',
status: 'approved',
created_at: now,
updated_at: now,
},
{
user_id: 4,
room_id: roomId(6),
rating: 2,
comment: 'Some old equipment, needs maintenance.',
status: 'approved',
created_at: now,
updated_at: now,
},
{
user_id: 5,
room_id: roomId(7),
rating: 4,
comment: 'Reception staff very helpful, quick check-in.',
status: 'approved',
created_at: now,
updated_at: now,
},
];
await queryInterface.bulkInsert('reviews', reviews);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('reviews', null, {});
},
};

View File

@@ -0,0 +1,44 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Populate room amenities from the room_types table where available
// This uses a JOIN update; works on MySQL/MariaDB. If using Postgres,
// replace with appropriate UPDATE ... FROM syntax.
const dialect = queryInterface.sequelize.getDialect();
if (dialect === 'mysql' || dialect === 'mariadb') {
await queryInterface.sequelize.query(
`UPDATE rooms r
JOIN room_types rt ON r.room_type_id = rt.id
SET r.amenities = rt.amenities
WHERE rt.amenities IS NOT NULL`
);
} else if (dialect === 'postgres') {
await queryInterface.sequelize.query(
`UPDATE rooms
SET amenities = rt.amenities
FROM room_types rt
WHERE rooms.room_type_id = rt.id
AND rt.amenities IS NOT NULL`
);
} else {
// Generic fallback: fetch rows and update individually
const [roomTypes] = await queryInterface.sequelize.query(
`SELECT id, amenities FROM room_types WHERE amenities IS NOT NULL`
);
for (const rt of roomTypes) {
await queryInterface.bulkUpdate(
'rooms',
{ amenities: rt.amenities },
{ room_type_id: rt.id }
);
}
}
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkUpdate('rooms', { amenities: null }, {});
},
};

View File

@@ -0,0 +1,94 @@
const jwt = require('jsonwebtoken');
const { User } = require('../databases/models');
/**
* Verify JWT token and attach user to request
*/
const authenticateToken = async (req, res, next) => {
try {
// Get token from header
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({
status: 'error',
message: 'Access token is required'
});
}
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Get user from database
const user = await User.findByPk(decoded.userId, {
attributes: { exclude: ['password'] }
});
if (!user) {
return res.status(401).json({
status: 'error',
message: 'User not found'
});
}
// Attach user to request
req.user = user;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
status: 'error',
message: 'Token expired'
});
}
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({
status: 'error',
message: 'Invalid token'
});
}
next(error);
}
};
/**
* Check if user has required role
*/
const authorizeRoles = (...roles) => {
return async (req, res, next) => {
try {
if (!req.user) {
return res.status(401).json({
status: 'error',
message: 'Authentication required'
});
}
// Get user role
const userRole = req.user.role_id;
// Map role IDs to role names
const roleMap = { 1: 'admin', 2: 'staff', 3: 'customer' };
const userRoleName = roleMap[userRole];
if (!roles.includes(userRoleName)) {
return res.status(403).json({
status: 'error',
message: 'You do not have permission to access this resource'
});
}
next();
} catch (error) {
next(error);
}
};
};
module.exports = {
authenticateToken,
authorizeRoles
};

View File

@@ -0,0 +1,66 @@
/**
* Global Error Handler Middleware
*/
const errorHandler = (err, req, res, next) => {
// Log error for debugging
console.error('Error:', {
message: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
path: req.path,
method: req.method
});
// Default error status
const statusCode = err.statusCode || 500;
const status = err.status || 'error';
// Sequelize validation errors
if (err.name === 'SequelizeValidationError') {
return res.status(400).json({
status: 'error',
message: 'Validation error',
errors: err.errors.map(e => ({
field: e.path,
message: e.message
}))
});
}
// Sequelize unique constraint errors
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({
status: 'error',
message: 'Duplicate entry',
errors: err.errors.map(e => ({
field: e.path,
message: `${e.path} already exists`
}))
});
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
status: 'error',
message: 'Invalid token'
});
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
status: 'error',
message: 'Token expired'
});
}
// Send error response
res.status(statusCode).json({
status,
message: err.message || 'Internal server error',
...(process.env.NODE_ENV === 'development' && {
stack: err.stack
})
});
};
module.exports = errorHandler;

View File

@@ -0,0 +1,45 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs');
// Create uploads directory if it doesn't exist
const uploadsDir = path.join(__dirname, '../../uploads/rooms');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Configure storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadsDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, 'room-' + uniqueSuffix + ext);
}
});
// File filter - only images
const fileFilter = (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (extname && mimetype) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed (jpeg, jpg, png, gif, webp)'));
}
};
// Configure multer
const upload = multer({
storage: storage,
fileFilter: fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max file size
}
});
module.exports = upload;

View File

@@ -0,0 +1,23 @@
const { validationResult } = require('express-validator');
/**
* Middleware to check validation results
*/
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
status: 'error',
message: 'Validation failed',
errors: errors.array().map(err => ({
field: err.path || err.param,
message: err.msg
}))
});
}
next();
};
module.exports = validate;

View File

@@ -0,0 +1,176 @@
const {
User,
RefreshToken,
Role,
PasswordResetToken
} = require('../databases/models');
const { Op } = require('sequelize');
/**
* Auth Repository - Data access layer
* Handles database operations related to authentication
*/
class AuthRepository {
/**
* Find user by email
*/
async findUserByEmail(email, includeRole = false, includePassword = false) {
const options = {
where: { email }
};
// Include password for login/authentication purposes
if (includePassword) {
options.attributes = { exclude: [] }; // Include all attributes including password
}
if (includeRole) {
options.include = [{
model: Role,
as: 'role',
attributes: ['id', 'name']
}];
}
return await User.findOne(options);
}
/**
* Find user by ID
*/
async findUserById(userId, includeRole = false, includePassword = false) {
// By default exclude password from returned attributes for safety.
const options = {};
if (!includePassword) {
options.attributes = { exclude: ['password'] };
}
if (includeRole) {
options.include = [{
model: Role,
as: 'role',
attributes: ['id', 'name']
}];
}
return await User.findByPk(userId, options);
}
/**
* Create new user
*/
async createUser(userData) {
return await User.create(userData);
}
/**
* Update user password
*/
async updateUserPassword(userId, hashedPassword) {
const user = await User.findByPk(userId);
if (!user) {
throw new Error('User not found');
}
return await user.update({ password: hashedPassword });
}
/**
* Save refresh token
*/
async saveRefreshToken(userId, token, expiresAt) {
return await RefreshToken.create({
user_id: userId,
token,
expires_at: expiresAt
});
}
/**
* Find refresh token
*/
async findRefreshToken(token, userId) {
return await RefreshToken.findOne({
where: {
token,
user_id: userId
}
});
}
/**
* Delete refresh token
*/
async deleteRefreshToken(token) {
return await RefreshToken.destroy({
where: { token }
});
}
/**
* Delete all refresh tokens for user
*/
async deleteAllUserRefreshTokens(userId) {
return await RefreshToken.destroy({
where: { user_id: userId }
});
}
/**
* Save password reset token
*/
async savePasswordResetToken(userId, hashedToken, expiresAt) {
// Delete old tokens
await this.deletePasswordResetTokensByUser(userId);
// Create new token
return await PasswordResetToken.create({
user_id: userId,
token: hashedToken,
expires_at: expiresAt
});
}
/**
* Find valid password reset token
*/
async findValidPasswordResetToken(hashedToken) {
return await PasswordResetToken.findOne({
where: {
token: hashedToken,
expires_at: {
[Op.gt]: new Date()
}
}
});
}
/**
* Delete password reset token
*/
async deletePasswordResetToken(tokenId) {
const token = await PasswordResetToken.findByPk(tokenId);
if (token) {
return await token.destroy();
}
}
/**
* Delete all password reset tokens for user
*/
async deletePasswordResetTokensByUser(userId) {
return await PasswordResetToken.destroy({
where: { user_id: userId }
});
}
/**
* Check if email exists
*/
async isEmailExists(email) {
const user = await User.findOne({ where: { email } });
return !!user;
}
}
module.exports = new AuthRepository();

View File

@@ -0,0 +1,77 @@
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const { authenticateToken } = require('../middlewares/auth');
const validate = require('../middlewares/validate');
const {
registerValidation,
loginValidation,
refreshTokenValidation
} = require('../validators/authValidator');
/**
* @route POST /api/auth/register
* @desc Register new user
* @access Public
*/
router.post(
'/register',
registerValidation,
validate,
authController.register
);
/**
* @route POST /api/auth/login
* @desc Login user
* @access Public
*/
router.post(
'/login',
loginValidation,
validate,
authController.login
);
/**
* @route POST /api/auth/refresh-token
* @desc Refresh access token
* @access Public
*/
router.post(
'/refresh-token',
refreshTokenValidation,
validate,
authController.refreshAccessToken
);
/**
* @route POST /api/auth/logout
* @desc Logout user
* @access Public
*/
router.post('/logout', authController.logout);
/**
* @route GET /api/auth/profile
* @desc Get current user profile
* @access Private
*/
router.get('/profile', authenticateToken, authController.getProfile);
/**
* @route POST /api/auth/forgot-password
* @desc Send password reset link
* @access Public
*/
router.post('/forgot-password', authController.forgotPassword);
/**
* @route POST /api/auth/reset-password
* @desc Reset password with token
* @access Public
*/
router.post('/reset-password', authController.resetPassword);
module.exports = router;

View File

@@ -0,0 +1,34 @@
const express = require('express');
const router = express.Router();
const bannerController = require('../controllers/bannerController');
const { authenticateToken, authorizeRoles } = require('../middlewares/auth');
/**
* Banner Routes
*/
// Public routes
router.get('/', bannerController.getBanners);
router.get('/:id', bannerController.getBannerById);
// Admin routes
router.post(
'/',
authenticateToken,
authorizeRoles('admin'),
bannerController.createBanner
);
router.put(
'/:id',
authenticateToken,
authorizeRoles('admin'),
bannerController.updateBanner
);
router.delete(
'/:id',
authenticateToken,
authorizeRoles('admin'),
bannerController.deleteBanner
);
module.exports = router;

View File

@@ -0,0 +1,44 @@
const express = require('express');
const router = express.Router();
const { authenticateToken, authorizeRoles } = require('../middlewares/auth');
const bookingController = require('../controllers/bookingController');
// Get all bookings (Admin/Staff only)
// GET /api/bookings
router.get(
'/',
authenticateToken,
authorizeRoles('admin', 'staff'),
bookingController.getAllBookings
);
// Create a new booking
// POST /api/bookings
router.post('/', authenticateToken, bookingController.createBooking);
// Get bookings for current user
// GET /api/bookings/me
router.get('/me', authenticateToken, bookingController.getMyBookings);
// Get booking by id
// GET /api/bookings/:id
router.get('/:id', authenticateToken, bookingController.getBookingById);
// Update booking status (Admin/Staff only)
// PUT /api/bookings/:id
router.put(
'/:id',
authenticateToken,
authorizeRoles('admin', 'staff'),
bookingController.updateBooking
);
// Cancel booking
// PATCH /api/bookings/:id/cancel
router.patch('/:id/cancel', authenticateToken, bookingController.cancelBooking);
// Check booking by booking number
// GET /api/bookings/check/:bookingNumber
router.get('/check/:bookingNumber', bookingController.checkBookingByNumber);
module.exports = router;

View File

@@ -0,0 +1,41 @@
const express = require('express');
const router = express.Router();
const favoriteController = require(
'../controllers/favoriteController'
);
const { authenticateToken } = require('../middlewares/auth');
/**
* Favorite Routes
* All routes require authentication
*/
// Get user's favorites
router.get(
'/',
authenticateToken,
favoriteController.getFavorites
);
// Check if room is favorited
router.get(
'/check/:roomId',
authenticateToken,
favoriteController.checkFavorite
);
// Add room to favorites
router.post(
'/:roomId',
authenticateToken,
favoriteController.addFavorite
);
// Remove room from favorites
router.delete(
'/:roomId',
authenticateToken,
favoriteController.removeFavorite
);
module.exports = router;

View File

@@ -0,0 +1,41 @@
const express = require('express');
const router = express.Router();
const paymentController = require('../controllers/paymentController');
const { authenticateToken } = require('../middlewares/auth');
/**
* Payment Routes
* All routes require authentication
*/
// Get payments for a booking
router.get(
'/booking/:bookingId',
authenticateToken,
paymentController.getPaymentByBookingId
);
// Get bank transfer info (QR code)
router.get(
'/:paymentId/bank-info',
authenticateToken,
paymentController.getBankTransferInfo
);
// Confirm deposit payment
router.post(
'/confirm-deposit',
authenticateToken,
paymentController.confirmDepositPayment
);
// Notify payment completion
router.post(
'/notify',
authenticateToken,
paymentController.notifyPayment
);
// (VNPay integration removed) — VNPay-related routes deleted
module.exports = router;

View File

@@ -0,0 +1,65 @@
const express = require('express');
const router = express.Router();
const promotionController = require('../controllers/promotionController');
const { authenticateToken, authorizeRoles } = require('../middlewares/auth');
/**
* GET /api/promotions - Get all promotions (Admin/Staff)
*/
router.get(
'/',
authenticateToken,
authorizeRoles('admin', 'staff'),
promotionController.getPromotions
);
/**
* GET /api/promotions/:id - Get promotion by ID (Admin/Staff)
*/
router.get(
'/:id',
authenticateToken,
authorizeRoles('admin', 'staff'),
promotionController.getPromotionById
);
/**
* POST /api/promotions - Create new promotion (Admin)
*/
router.post(
'/',
authenticateToken,
authorizeRoles('admin'),
promotionController.createPromotion
);
/**
* PUT /api/promotions/:id - Update promotion (Admin)
*/
router.put(
'/:id',
authenticateToken,
authorizeRoles('admin'),
promotionController.updatePromotion
);
/**
* DELETE /api/promotions/:id - Delete promotion (Admin)
*/
router.delete(
'/:id',
authenticateToken,
authorizeRoles('admin'),
promotionController.deletePromotion
);
/**
* POST /api/promotions/validate - Validate and apply promotion code (Authenticated)
*/
router.post(
'/validate',
authenticateToken,
promotionController.validatePromotion
);
module.exports = router;

View File

@@ -0,0 +1,36 @@
const express = require('express');
const router = express.Router();
const reportController = require('../controllers/reportController');
const { authenticateToken, authorizeRoles } = require('../middlewares/auth');
/**
* GET /api/reports/dashboard - Get dashboard statistics (Admin/Staff)
*/
router.get(
'/dashboard',
authenticateToken,
authorizeRoles('admin', 'staff'),
reportController.getDashboardStats
);
/**
* GET /api/reports - Get detailed reports (Admin/Staff)
*/
router.get(
'/',
authenticateToken,
authorizeRoles('admin', 'staff'),
reportController.getReports
);
/**
* GET /api/reports/export - Export report to CSV (Admin/Staff)
*/
router.get(
'/export',
authenticateToken,
authorizeRoles('admin', 'staff'),
reportController.exportReport
);
module.exports = router;

View File

@@ -0,0 +1,46 @@
const express = require('express');
const router = express.Router();
const reviewController = require('../controllers/reviewController');
const {
authenticateToken,
authorizeRoles
} = require('../middlewares/auth');
/**
* Review Routes
* Base path: /api/reviews
*/
// Admin: Get all reviews
router.get('/',
authenticateToken,
authorizeRoles('admin', 'staff'),
reviewController.getAllReviews
);
// Protected: Create a new review (authenticated users)
router.post('/',
authenticateToken,
reviewController.createReview
);
// Public: Get reviews for a specific room
router.get('/room/:roomId',
reviewController.getRoomReviews
);
// Admin: Approve review
router.patch('/:id/approve',
authenticateToken,
authorizeRoles('admin', 'staff'),
reviewController.approveReview
);
// Admin: Reject review
router.patch('/:id/reject',
authenticateToken,
authorizeRoles('admin', 'staff'),
reviewController.rejectReview
);
module.exports = router;

View File

@@ -0,0 +1,55 @@
const express = require('express');
const router = express.Router();
const roomController = require('../controllers/roomController');
const reviewController = require('../controllers/reviewController');
const { authenticateToken, authorizeRoles } = require('../middlewares/auth');
const upload = require('../middlewares/upload');
/**
* Room Routes
*/
// Public routes
router.get('/', roomController.getRooms);
router.get('/amenities', roomController.getAmenities);
router.get('/available', roomController.searchAvailableRooms);
router.get('/:id', roomController.getRoomById);
// Public: Get reviews for a specific room (support /api/rooms/:id/reviews)
router.get('/:id/reviews', reviewController.getRoomReviews);
// Admin routes
router.post(
'/',
authenticateToken,
authorizeRoles('admin'),
roomController.createRoom
);
router.put(
'/:id',
authenticateToken,
authorizeRoles('admin'),
roomController.updateRoom
);
router.delete(
'/:id',
authenticateToken,
authorizeRoles('admin'),
roomController.deleteRoom
);
// Image upload routes
router.post(
'/:id/images',
authenticateToken,
authorizeRoles('admin', 'staff'),
upload.array('images', 5), // Max 5 images at once
roomController.uploadRoomImages
);
router.delete(
'/:id/images',
authenticateToken,
authorizeRoles('admin', 'staff'),
roomController.deleteRoomImage
);
module.exports = router;

View File

@@ -0,0 +1,56 @@
const express = require('express');
const router = express.Router();
const serviceController = require('../controllers/serviceController');
const { authenticateToken, authorizeRoles } = require('../middlewares/auth');
/**
* GET /api/services - Get all services
*/
router.get('/', serviceController.getServices);
/**
* GET /api/services/:id - Get service by ID
*/
router.get('/:id', serviceController.getServiceById);
/**
* POST /api/services - Create new service (Admin/Staff)
*/
router.post(
'/',
authenticateToken,
authorizeRoles('admin', 'staff'),
serviceController.createService
);
/**
* PUT /api/services/:id - Update service (Admin/Staff)
*/
router.put(
'/:id',
authenticateToken,
authorizeRoles('admin', 'staff'),
serviceController.updateService
);
/**
* DELETE /api/services/:id - Delete service (Admin)
*/
router.delete(
'/:id',
authenticateToken,
authorizeRoles('admin'),
serviceController.deleteService
);
/**
* POST /api/services/use - Add service to booking (Admin/Staff)
*/
router.post(
'/use',
authenticateToken,
authorizeRoles('admin', 'staff'),
serviceController.useService
);
module.exports = router;

View File

@@ -0,0 +1,57 @@
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { authenticateToken, authorizeRoles } =
require('../middlewares/auth');
/**
* GET /api/users - Get all users (Admin/Staff)
*/
router.get(
'/',
authenticateToken,
authorizeRoles('admin', 'staff'),
userController.getUsers
);
/**
* GET /api/users/:id - Get user by ID (Admin/Staff)
*/
router.get(
'/:id',
authenticateToken,
authorizeRoles('admin', 'staff'),
userController.getUserById
);
/**
* POST /api/users - Create new user (Admin)
*/
router.post(
'/',
authenticateToken,
authorizeRoles('admin'),
userController.createUser
);
/**
* PUT /api/users/:id - Update user (Admin)
*/
router.put(
'/:id',
authenticateToken,
authorizeRoles('admin'),
userController.updateUser
);
/**
* DELETE /api/users/:id - Delete user (Admin)
*/
router.delete(
'/:id',
authenticateToken,
authorizeRoles('admin'),
userController.deleteUser
);
module.exports = router;

47
server/src/server.js Normal file
View File

@@ -0,0 +1,47 @@
const app = require('./app');
const db = require('./databases/models');
const PORT = process.env.PORT || 3000;
// Test database connection
const startServer = async () => {
try {
// Test database connection
await db.sequelize.authenticate();
console.log('✅ Database connection established successfully');
// Sync models (only in development)
if (process.env.NODE_ENV === 'development') {
// await db.sequelize.sync({ alter: true });
console.log('📊 Database models synced');
}
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
console.log(
`🌐 Environment: ${process.env.NODE_ENV || 'development'}`
);
console.log(`🔗 API: http://localhost:${PORT}/api`);
console.log(`🏥 Health: http://localhost:${PORT}/health`);
});
} catch (error) {
console.error('❌ Unable to start server:', error);
process.exit(1);
}
};
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.error('❌ Unhandled Rejection:', err);
process.exit(1);
});
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
console.error('❌ Uncaught Exception:', err);
process.exit(1);
});
// Start the server
startServer();

View File

@@ -0,0 +1,435 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const authRepository = require('../repositories/authRepository');
const { sendEmail } = require('../utils/mailer');
/**
* Auth Service - Business logic layer
* Handles authentication business logic
*/
class AuthService {
/**
* Generate JWT tokens
*/
generateTokens(userId) {
const accessToken = jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '1h' }
);
const refreshToken = jwt.sign(
{ userId },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' }
);
return { accessToken, refreshToken };
}
/**
* Verify JWT token
*/
verifyAccessToken(token) {
return jwt.verify(token, process.env.JWT_SECRET);
}
/**
* Verify refresh token
*/
verifyRefreshToken(token) {
return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
}
/**
* Hash password
*/
async hashPassword(password) {
return await bcrypt.hash(password, 10);
}
/**
* Compare password
*/
async comparePassword(password, hashedPassword) {
return await bcrypt.compare(password, hashedPassword);
}
/**
* Format user response
*/
formatUserResponse(user) {
return {
id: user.id,
name: user.full_name,
email: user.email,
phone: user.phone,
role: user.role ? user.role.name : 'customer',
createdAt: user.created_at,
updatedAt: user.updated_at
};
}
/**
* Register new user
*/
async register(data) {
const { name, email, password, phone } = data;
// Check if email exists
const emailExists = await authRepository.isEmailExists(email);
if (emailExists) {
throw new Error('Email already registered');
}
// Hash password
const hashedPassword = await this.hashPassword(password);
// Create user (default role_id = 3 for customer)
const user = await authRepository.createUser({
full_name: name,
email,
password: hashedPassword,
phone,
role_id: 3 // Customer role
});
// Generate tokens
const { accessToken, refreshToken } =
this.generateTokens(user.id);
// Save refresh token (expires in 7 days)
const expiresAt = new Date(
Date.now() + 7 * 24 * 60 * 60 * 1000
);
await authRepository.saveRefreshToken(
user.id,
refreshToken,
expiresAt
);
// Remove password from response
const userResponse = user.toJSON();
delete userResponse.password;
// Send welcome email (non-blocking)
try {
await sendEmail({
to: user.email,
subject: 'Welcome to Hotel Booking',
html: `
<div style="font-family: Arial, sans-serif;
max-width: 600px; margin: 0 auto;">
<h2 style="color: #4F46E5;">
Welcome ${user.full_name}!
</h2>
<p>Thank you for registering an account at
<strong>Hotel Booking</strong>.</p>
<p>Your account has been successfully created with
email: <strong>${user.email}</strong></p>
<div style="background-color: #F3F4F6;
padding: 20px; border-radius: 8px;
margin: 20px 0;">
<p style="margin: 0;">
<strong>You can:</strong>
</p>
<ul style="margin-top: 10px;">
<li>Search and book hotel rooms</li>
<li>Manage your bookings</li>
<li>Update your personal information</li>
</ul>
</div>
<p>
<a href="${process.env.CLIENT_URL}/login"
style="background-color: #4F46E5;
color: white; padding: 12px 24px;
text-decoration: none; border-radius: 6px;
display: inline-block;">
Login Now
</a>
</p>
<p style="color: #6B7280; font-size: 14px;
margin-top: 30px;">
If you have any questions, please
contact us.
</p>
</div>
`
});
} catch (err) {
console.error('Failed to send welcome email:', err);
// Don't fail registration if email fails
}
return {
user: userResponse,
token: accessToken,
refreshToken
};
}
/**
* Login user
*/
async login(data) {
const { email, password, rememberMe } = data;
// Find user with role and password (needed for authentication)
const user = await authRepository.findUserByEmail(
email,
true, // includeRole
true // includePassword - needed to verify password
);
if (!user) {
throw new Error('Invalid email or password');
}
// Check password
const isPasswordValid = await this.comparePassword(
password,
user.password
);
if (!isPasswordValid) {
throw new Error('Invalid email or password');
}
// Generate tokens
const { accessToken, refreshToken } =
this.generateTokens(user.id);
// Calculate expiry based on rememberMe
const expiryDays = rememberMe ? 7 : 1;
const expiresAt = new Date(
Date.now() + expiryDays * 24 * 60 * 60 * 1000
);
// Save refresh token
await authRepository.saveRefreshToken(
user.id,
refreshToken,
expiresAt
);
// Format user response
const userResponse = this.formatUserResponse(user);
return {
user: userResponse,
token: accessToken,
refreshToken
};
}
/**
* Refresh access token
*/
async refreshAccessToken(refreshToken) {
if (!refreshToken) {
throw new Error('Refresh token is required');
}
// Verify refresh token
const decoded = this.verifyRefreshToken(refreshToken);
// Check if refresh token exists in database
const storedToken = await authRepository.findRefreshToken(
refreshToken,
decoded.userId
);
if (!storedToken) {
throw new Error('Invalid refresh token');
}
// Check if token is expired
if (new Date() > storedToken.expires_at) {
await authRepository.deleteRefreshToken(refreshToken);
throw new Error('Refresh token expired');
}
// Generate new access token
const accessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '1h' }
);
return { token: accessToken };
}
/**
* Logout user
*/
async logout(refreshToken) {
if (refreshToken) {
await authRepository.deleteRefreshToken(refreshToken);
}
return true;
}
/**
* Get user profile
*/
async getProfile(userId) {
const user = await authRepository.findUserById(userId, true);
if (!user) {
throw new Error('User not found');
}
return this.formatUserResponse(user);
}
/**
* Generate reset token
*/
generateResetToken() {
const resetToken = crypto.randomBytes(32).toString('hex');
const hashedToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
return { resetToken, hashedToken };
}
/**
* Forgot Password - Send reset link
*/
async forgotPassword(email) {
// Find user by email
const user = await authRepository.findUserByEmail(email);
// Always return success to prevent email enumeration
if (!user) {
return {
success: true,
message: 'If email exists, reset link has been sent'
};
}
// Generate reset token
const { resetToken, hashedToken } =
this.generateResetToken();
// Save token (expires in 1 hour)
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
await authRepository.savePasswordResetToken(
user.id,
hashedToken,
expiresAt
);
// Build reset URL
const resetUrl =
`${process.env.CLIENT_URL}/reset-password/${resetToken}`;
// Try to send email
try {
await sendEmail({
to: user.email,
subject: 'Reset password - Hotel Booking',
html: `
<p>You (or someone) has requested to reset your password.</p>
<p>Click the link below to reset your password
(expires in 1 hour):</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
`
});
} catch (err) {
console.error('Failed to send reset email:', err);
// Do NOT log the raw reset token or URL in production.
// Errors are logged above; token must remain secret.
}
return {
success: true,
message: 'Password reset link has been sent to your email'
};
}
/**
* Reset Password - Update password with token
*/
async resetPassword(data) {
const { token, password } = data;
if (!token || !password) {
throw new Error('Token and password are required');
}
// Hash the token to compare
const hashedToken = crypto
.createHash('sha256')
.update(token)
.digest('hex');
// Find valid token
const resetToken =
await authRepository.findValidPasswordResetToken(
hashedToken
);
if (!resetToken) {
throw new Error('Invalid or expired reset token');
}
// Find user (include password so we can compare)
const user = await authRepository.findUserById(
resetToken.user_id,
false, // includeRole
true // includePassword
);
if (!user) {
throw new Error('User not found');
}
// Check if new password matches old password
const isSamePassword = await this.comparePassword(
password,
user.password
);
if (isSamePassword) {
// Return error message to the client
throw new Error('New password must be different from the old password');
}
// Hash new password
const hashedPassword = await this.hashPassword(password);
// Update password
await authRepository.updateUserPassword(
user.id,
hashedPassword
);
// Delete used token
await authRepository.deletePasswordResetToken(resetToken.id);
// Send confirmation email (non-blocking)
try {
await sendEmail({
to: user.email,
subject: 'Password Changed',
html: `
<p>The password for account ${user.email} has been changed
successfully.</p>
`
});
} catch (err) {
console.error('Failed to send confirmation email:', err);
}
return {
success: true,
message: 'Password has been reset successfully'
};
}
}
module.exports = new AuthService();

View File

@@ -0,0 +1,50 @@
const nodemailer = require('nodemailer');
/**
* sendEmail helper - production-only SMTP sender
* Requires MAIL_HOST, MAIL_USER and MAIL_PASS to be set in env.
* This helper intentionally does NOT fallback to Ethereal or
* log preview URLs. Raw tokens or reset URLs must never be
* printed to logs in production.
*/
async function sendEmail({ to, subject, html, text }) {
// Require SMTP credentials to be present
const hasSmtpConfig = !!(
process.env.MAIL_HOST &&
process.env.MAIL_USER &&
process.env.MAIL_PASS
);
if (!hasSmtpConfig) {
throw new Error(
'SMTP mailer not configured. Set MAIL_HOST, MAIL_USER and MAIL_PASS in env.'
);
}
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: Number(process.env.MAIL_PORT) || 587,
secure: process.env.MAIL_SECURE === 'true',
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS
}
});
const fromAddress = process.env.MAIL_FROM ||
`no-reply@${(process.env.CLIENT_URL || 'example.com')
.replace(/^https?:\/\//, '')}`;
// Send and let errors bubble up to caller for handling/logging
const info = await transporter.sendMail({
from: fromAddress,
to,
subject,
text,
html
});
return info;
}
module.exports = { sendEmail };

View File

@@ -0,0 +1,18 @@
/**
* VNPay integration removed
* This file is intentionally left as a stub to indicate the VNPay
* payment gateway has been removed from the project. Any attempt
* to require this module should be considered a usage error.
*/
module.exports = {
// No-op placeholders
createPaymentUrl: () => {
throw new Error('VNPay integration has been removed');
},
verifyReturn: () => {
throw new Error('VNPay integration has been removed');
},
sortObject: () => ({}),
createSignature: () => '',
};

View File

@@ -0,0 +1,70 @@
const { body } = require('express-validator');
/**
* Validation rules for user registration
*/
const registerValidation = [
body('name')
.trim()
.notEmpty()
.withMessage('Name is required')
.isLength({ min: 2, max: 50 })
.withMessage('Name must be between 2 and 50 characters'),
body('email')
.trim()
.notEmpty()
.withMessage('Email is required')
.isEmail()
.withMessage('Invalid email format')
.normalizeEmail(),
body('password')
.notEmpty()
.withMessage('Password is required')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage(
'Password must contain uppercase, lowercase, and number'
),
body('phone')
.optional()
.trim()
.matches(/^[0-9]{10,11}$/)
.withMessage('Phone must be 10-11 digits')
];
/**
* Validation rules for user login
*/
const loginValidation = [
body('email')
.trim()
.notEmpty()
.withMessage('Email is required')
.isEmail()
.withMessage('Invalid email format')
.normalizeEmail(),
body('password')
.notEmpty()
.withMessage('Password is required')
];
/**
* Validation rules for refresh token
* Accept token from request body or HttpOnly cookie
*/
const refreshTokenValidation = [
body('refreshToken')
.notEmpty()
.withMessage('Refresh token is required')
];
module.exports = {
registerValidation,
loginValidation,
refreshTokenValidation
};

7
server/test-vnpay.js Normal file
View File

@@ -0,0 +1,7 @@
/**
* VNPay Integration Test Script
* Test từng bước để verify signature và URL generation
*/
// VNPay test script removed per repository cleanup.
// This file left intentionally empty.

1
server/tmp_login.json Normal file
View File

@@ -0,0 +1 @@
{"status":"success","message":"Login successful","data":{"user":{"id":4,"name":"Nguyen Van A","email":"customer1@gmail.com","phone":"0904567890","role":"customer","createdAt":"2025-11-14T07:32:28.000Z","updatedAt":"2025-11-14T07:32:28.000Z"},"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQsImlhdCI6MTc2MzEyNzU0NywiZXhwIjoxNzYzMTMxMTQ3fQ.WpjV_sqePKNeZWOwYees44Qfo-WeErk7dMZ9przeUqQ"}}