Hotel Booking
This commit is contained in:
35
server/.env.example
Normal file
35
server/.env.example
Normal 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
42
server/.gitignore
vendored
Normal 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
8
server/.sequelizerc
Normal 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
165
server/QUICK_START.md
Normal 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
166
server/README.md
Normal 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
44
server/package.json
Normal 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
128
server/src/app.js
Normal 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;
|
||||
70
server/src/config/database.js
Normal file
70
server/src/config/database.js
Normal 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'
|
||||
}
|
||||
}
|
||||
};
|
||||
244
server/src/controllers/authController.js
Normal file
244
server/src/controllers/authController.js
Normal 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
|
||||
};
|
||||
222
server/src/controllers/bannerController.js
Normal file
222
server/src/controllers/bannerController.js
Normal 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,
|
||||
};
|
||||
363
server/src/controllers/bookingController.js
Normal file
363
server/src/controllers/bookingController.js
Normal 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,
|
||||
};
|
||||
205
server/src/controllers/favoriteController.js
Normal file
205
server/src/controllers/favoriteController.js
Normal 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,
|
||||
};
|
||||
BIN
server/src/controllers/paymentController.js
Normal file
BIN
server/src/controllers/paymentController.js
Normal file
Binary file not shown.
353
server/src/controllers/promotionController.js
Normal file
353
server/src/controllers/promotionController.js
Normal 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,
|
||||
};
|
||||
425
server/src/controllers/reportController.js
Normal file
425
server/src/controllers/reportController.js
Normal 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,
|
||||
};
|
||||
233
server/src/controllers/reviewController.js
Normal file
233
server/src/controllers/reviewController.js
Normal 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,
|
||||
};
|
||||
720
server/src/controllers/roomController.js
Normal file
720
server/src/controllers/roomController.js
Normal 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,
|
||||
};
|
||||
298
server/src/controllers/serviceController.js
Normal file
298
server/src/controllers/serviceController.js
Normal 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,
|
||||
};
|
||||
320
server/src/controllers/userController.js
Normal file
320
server/src/controllers/userController.js
Normal 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,
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
84
server/src/databases/models/Banner.js
Normal file
84
server/src/databases/models/Banner.js
Normal 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;
|
||||
};
|
||||
130
server/src/databases/models/Booking.js
Normal file
130
server/src/databases/models/Booking.js
Normal 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;
|
||||
};
|
||||
88
server/src/databases/models/CheckInCheckOut.js
Normal file
88
server/src/databases/models/CheckInCheckOut.js
Normal 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;
|
||||
};
|
||||
55
server/src/databases/models/Favorite.js
Normal file
55
server/src/databases/models/Favorite.js
Normal 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;
|
||||
};
|
||||
49
server/src/databases/models/PasswordResetToken.js
Normal file
49
server/src/databases/models/PasswordResetToken.js
Normal 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;
|
||||
};
|
||||
106
server/src/databases/models/Payment.js
Normal file
106
server/src/databases/models/Payment.js
Normal 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;
|
||||
};
|
||||
151
server/src/databases/models/Promotion.js
Normal file
151
server/src/databases/models/Promotion.js
Normal 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;
|
||||
};
|
||||
49
server/src/databases/models/RefreshToken.js
Normal file
49
server/src/databases/models/RefreshToken.js
Normal 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;
|
||||
};
|
||||
61
server/src/databases/models/Review.js
Normal file
61
server/src/databases/models/Review.js
Normal 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;
|
||||
};
|
||||
49
server/src/databases/models/Role.js
Normal file
49
server/src/databases/models/Role.js
Normal 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;
|
||||
};
|
||||
107
server/src/databases/models/Room.js
Normal file
107
server/src/databases/models/Room.js
Normal 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;
|
||||
};
|
||||
66
server/src/databases/models/RoomType.js
Normal file
66
server/src/databases/models/RoomType.js
Normal 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;
|
||||
};
|
||||
63
server/src/databases/models/Service.js
Normal file
63
server/src/databases/models/Service.js
Normal 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;
|
||||
};
|
||||
81
server/src/databases/models/ServiceUsage.js
Normal file
81
server/src/databases/models/ServiceUsage.js
Normal 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;
|
||||
};
|
||||
121
server/src/databases/models/User.js
Normal file
121
server/src/databases/models/User.js
Normal 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;
|
||||
};
|
||||
53
server/src/databases/models/index.js
Normal file
53
server/src/databases/models/index.js
Normal 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;
|
||||
35
server/src/databases/seeders/20250101000001-seed-roles.js
Normal file
35
server/src/databases/seeders/20250101000001-seed-roles.js
Normal 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, {});
|
||||
}
|
||||
};
|
||||
95
server/src/databases/seeders/20250101000002-seed-users.js
Normal file
95
server/src/databases/seeders/20250101000002-seed-users.js
Normal 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, {});
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
'Wi‑Fi',
|
||||
'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, {});
|
||||
}
|
||||
};
|
||||
173
server/src/databases/seeders/20250101000004-seed-rooms.js
Normal file
173
server/src/databases/seeders/20250101000004-seed-rooms.js
Normal 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, {});
|
||||
}
|
||||
};
|
||||
137
server/src/databases/seeders/20250101000005-seed-services.js
Normal file
137
server/src/databases/seeders/20250101000005-seed-services.js
Normal 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, {});
|
||||
}
|
||||
};
|
||||
140
server/src/databases/seeders/20250101000006-seed-promotions.js
Normal file
140
server/src/databases/seeders/20250101000006-seed-promotions.js
Normal 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, {});
|
||||
}
|
||||
};
|
||||
94
server/src/databases/seeders/20250101000007-seed-banners.js
Normal file
94
server/src/databases/seeders/20250101000007-seed-banners.js
Normal 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, {});
|
||||
}
|
||||
};
|
||||
124
server/src/databases/seeders/20250101000008-seed-bookings.js
Normal file
124
server/src/databases/seeders/20250101000008-seed-bookings.js
Normal 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, {});
|
||||
}
|
||||
};
|
||||
86
server/src/databases/seeders/20250101000009-seed-payments.js
Normal file
86
server/src/databases/seeders/20250101000009-seed-payments.js
Normal 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, {});
|
||||
}
|
||||
};
|
||||
@@ -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, {});
|
||||
}
|
||||
};
|
||||
@@ -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, {});
|
||||
}
|
||||
};
|
||||
104
server/src/databases/seeders/20250101000012-seed-reviews.js
Normal file
104
server/src/databases/seeders/20250101000012-seed-reviews.js
Normal 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, {});
|
||||
},
|
||||
};
|
||||
@@ -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 }, {});
|
||||
},
|
||||
};
|
||||
94
server/src/middlewares/auth.js
Normal file
94
server/src/middlewares/auth.js
Normal 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
|
||||
};
|
||||
66
server/src/middlewares/errorHandler.js
Normal file
66
server/src/middlewares/errorHandler.js
Normal 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;
|
||||
45
server/src/middlewares/upload.js
Normal file
45
server/src/middlewares/upload.js
Normal 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;
|
||||
23
server/src/middlewares/validate.js
Normal file
23
server/src/middlewares/validate.js
Normal 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;
|
||||
176
server/src/repositories/authRepository.js
Normal file
176
server/src/repositories/authRepository.js
Normal 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();
|
||||
77
server/src/routes/authRoutes.js
Normal file
77
server/src/routes/authRoutes.js
Normal 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;
|
||||
34
server/src/routes/bannerRoutes.js
Normal file
34
server/src/routes/bannerRoutes.js
Normal 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;
|
||||
44
server/src/routes/bookingRoutes.js
Normal file
44
server/src/routes/bookingRoutes.js
Normal 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;
|
||||
41
server/src/routes/favoriteRoutes.js
Normal file
41
server/src/routes/favoriteRoutes.js
Normal 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;
|
||||
41
server/src/routes/paymentRoutes.js
Normal file
41
server/src/routes/paymentRoutes.js
Normal 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;
|
||||
65
server/src/routes/promotionRoutes.js
Normal file
65
server/src/routes/promotionRoutes.js
Normal 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;
|
||||
36
server/src/routes/reportRoutes.js
Normal file
36
server/src/routes/reportRoutes.js
Normal 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;
|
||||
46
server/src/routes/reviewRoutes.js
Normal file
46
server/src/routes/reviewRoutes.js
Normal 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;
|
||||
55
server/src/routes/roomRoutes.js
Normal file
55
server/src/routes/roomRoutes.js
Normal 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;
|
||||
56
server/src/routes/serviceRoutes.js
Normal file
56
server/src/routes/serviceRoutes.js
Normal 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;
|
||||
57
server/src/routes/userRoutes.js
Normal file
57
server/src/routes/userRoutes.js
Normal 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
47
server/src/server.js
Normal 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();
|
||||
435
server/src/services/authService.js
Normal file
435
server/src/services/authService.js
Normal 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();
|
||||
50
server/src/utils/mailer.js
Normal file
50
server/src/utils/mailer.js
Normal 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 };
|
||||
18
server/src/utils/vnpayService.js
Normal file
18
server/src/utils/vnpayService.js
Normal 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: () => '',
|
||||
};
|
||||
70
server/src/validators/authValidator.js
Normal file
70
server/src/validators/authValidator.js
Normal 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
7
server/test-vnpay.js
Normal 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
1
server/tmp_login.json
Normal 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"}}
|
||||
Reference in New Issue
Block a user