A full-stack hotel booking platform with role-based access control, real-time Redis caching, Stripe payments, and Cloudinary image management.
- Project Overview
- Tech Stack
- Architecture
- Repository Structure
- Environment Variables
- Getting Started
- Backend — API Reference
- Data Models
- Role-Based Access Control
- Backend Architecture Patterns
- Frontend Architecture
- Security Implementation
- Caching Strategy
- Payment Flow
- Docker Setup
- Middleware Reference
- Admin and Approval Workflow
- User Support System
- UI Skeleton Loading System
SpanStay is a hotel booking web application that enables:
- Users to browse hotels, make bookings, and pay securely via Stripe.
- Hotel Admins to list hotels, manage their properties, confirm or cancel bookings.
- Platform Admins to oversee all content and users.
The project is split into two independent workspaces:
backend/— Node.js/Express REST APIvite-frontend/— React 19 SPA (Vite + TailwindCSS)
| Category | Technology |
|---|---|
| Runtime | Node.js (ESM) |
| Framework | Express 5 |
| Database | MongoDB (via Mongoose 9) |
| Caching | Redis 7 |
| Authentication | JWT (Access + Refresh token rotation) |
| Payments | Stripe |
| Image Storage | Cloudinary |
| File Uploads | Multer + multer-storage-cloudinary |
| Validation | Zod |
| API Docs | Swagger (OpenAPI 3.0 via swagger-jsdoc) |
| Security | Helmet, HPP, CORS, express-rate-limit |
| Logging | Morgan |
| Containerization | Docker + Docker Compose |
| Category | Technology |
|---|---|
| Framework | React 19 |
| Build Tool | Vite 8 |
| Styling | TailwindCSS 4 |
| State Management | Redux Toolkit |
| Routing | React Router DOM 7 |
| Forms | React Hook Form + Zod |
| HTTP Client | Axios |
| Animations | Framer Motion |
| Payments | @stripe/stripe-js |
| Notifications | Sonner |
| Icons | Lucide React |
| Date Utilities | date-fns |
┌─────────────────────────────────────────────────────┐
│ Client (Browser) │
│ React 19 SPA (Vite + TailwindCSS) │
└────────────────────────┬────────────────────────────┘
│ HTTP / REST
▼
┌─────────────────────────────────────────────────────┐
│ Express 5 API (Node.js ESM) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌───────┐ │
│ │ Auth │ │ Hotels │ │Bookings │ │Payment│ │
│ │ Module │ │ Module │ │ Module │ │Module │ │
│ └────┬────┘ └────┬─────┘ └────┬────┘ └───┬───┘ │
│ └────────────┴─────────────┴────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ │
│ │ MongoDB │ │ Redis │ │ Cloudinary│ │
│ │ (data) │ │ (cache) │ │ (images)│ │
│ └─────────┘ └──────────┘ └──────────┘ │
│ │
│ Stripe Webhooks ◄──────────────────────────►│
└─────────────────────────────────────────────────────┘
spanstay/
├── backend/
│ ├── src/
│ │ ├── app.js # Express app configuration
│ │ ├── server.js # Server entry point
│ │ ├── config/
│ │ │ ├── db.js # MongoDB connection
│ │ │ ├── redis.js # Redis client
│ │ │ ├── stripe.js # Stripe initialization
│ │ │ ├── cloudinary.js # Cloudinary configuration
│ │ │ ├── swagger.js # OpenAPI/Swagger spec
│ │ │ └── cookieOptions.js # Cookie configuration
│ │ ├── modules/
│ │ │ ├── auth/
│ │ │ │ ├── auth.routes.js
│ │ │ │ ├── auth.controller.js
│ │ │ │ ├── auth.service.js
│ │ │ │ ├── auth.repository.js
│ │ │ │ ├── auth.validation.js
│ │ │ │ └── user.model.js
│ │ │ ├── hotel/
│ │ │ │ ├── hotel.routes.js
│ │ │ │ ├── hotel.controller.js
│ │ │ │ ├── hotel.service.js
│ │ │ │ ├── hotel.repository.js
│ │ │ │ ├── hotel.validation.js
│ │ │ │ └── hotel.model.js
│ │ │ ├── booking/
│ │ │ │ ├── booking.routes.js
│ │ │ │ ├── booking.controller.js
│ │ │ │ ├── booking.service.js
│ │ │ │ ├── booking.repository.js
│ │ │ │ ├── booking.validation.js
│ │ │ │ └── booking.model.js
│ │ │ └── payment/
│ │ │ ├── payment.routes.js
│ │ │ ├── payment.controller.js
│ │ │ └── payment.service.js
│ │ ├── routes/
│ │ │ └── index.js # Central route aggregator
│ │ ├── shared/
│ │ │ ├── constants/
│ │ │ │ └── role.js # ROLES enum
│ │ │ ├── middleware/
│ │ │ │ ├── auth.middleware.js # JWT verification
│ │ │ │ ├── authorize.middleware.js # Role-based guard
│ │ │ │ ├── error.middleware.js # Global error handler
│ │ │ │ ├── fileUpload.middleware.js # Multer + Cloudinary
│ │ │ │ ├── rateLimit.middleware.js # Rate limiting
│ │ │ │ └── validate.middleware.js # Zod request validation
│ │ │ ├── utils/
│ │ │ │ ├── AppError.js # Custom error class
│ │ │ │ ├── generateToken.js # JWT helpers
│ │ │ │ └── clearHotelCache.js # Redis cache invalidation
│ │ │ └── validators/
│ │ │ └── hotel.validation.js # Shared param schemas
│ │ └── seeders/ # Database seed scripts
│ ├── Dockerfile
│ ├── docker-compose.yml
│ └── package.json
│
└── vite-frontend/
├── src/
│ ├── main.jsx # App entry point
│ ├── App.jsx # Root routing
│ ├── pages/
│ │ ├── auth/
│ │ │ ├── SigninPage.jsx
│ │ │ └── SignupPage.jsx
│ │ └── users/
│ │ └── UsersProfilePage.jsx
│ ├── components/
│ │ ├── auth/ # Auth-specific components
│ │ ├── shared/ # Reusable shared components
│ │ └── ui/ # UI primitives
│ ├── redux/
│ │ ├── api/ # RTK Query API slices
│ │ └── features/
│ │ └── auth/
│ │ ├── authSlice.js
│ │ └── authSelectors.js
│ ├── routes/
│ │ ├── ProtectedRoute.jsx # Auth guard
│ │ └── PublicRoute.jsx # Public route guard
│ ├── hooks/
│ │ ├── useAuth.js
│ │ └── useInitializeAuth.js
│ ├── layouts/ # Page layout wrappers
│ ├── schemas/ # Zod validation schemas
│ ├── service/ # Axios API service layer
│ ├── animations/ # Framer Motion variants
│ ├── constants/ # App-wide constants
│ ├── lib/ # Utility libraries
│ └── utils/ # Helper functions
└── package.json
| Variable | Description | Example |
|---|---|---|
PORT |
Server port | 4000 |
MONGODB_URI |
MongoDB connection string | mongodb://localhost:27017/spanstay |
REDIS_URL |
Redis connection URL | redis://localhost:6379 |
JWT_ACCESS_SECRET |
JWT access token secret | your_access_secret |
JWT_REFRESH_SECRET |
JWT refresh token secret | your_refresh_secret |
JWT_ACCESS_EXPIRES_IN |
Access token expiry | 15m |
JWT_REFRESH_EXPIRES_IN |
Refresh token expiry | 7d |
CLIENT_URL |
Frontend origin for CORS | http://localhost:5173 |
CLOUDINARY_CLOUD_NAME |
Cloudinary cloud name | your_cloud_name |
CLOUDINARY_API_KEY |
Cloudinary API key | your_api_key |
CLOUDINARY_API_SECRET |
Cloudinary API secret | your_api_secret |
STRIPE_SECRET_KEY |
Stripe secret key | sk_test_... |
STRIPE_WEBHOOK_SECRET |
Stripe webhook signing secret | whsec_... |
PAYMENT_SUCCESS_URL |
Redirect URL on payment success | http://localhost:5173/payment/success |
PAYMENT_CANCEL_URL |
Redirect URL on payment cancel | http://localhost:5173/payment/cancel |
| Variable | Description | Example |
|---|---|---|
VITE_API_BASE_URL |
Backend API base URL | http://localhost:4000/api/v1 |
VITE_STRIPE_PUBLISHABLE_KEY |
Stripe publishable key | pk_test_... |
- Node.js ≥ 18
- pnpm ≥ 8
- Docker & Docker Compose (for local infrastructure)
# 1. Clone the repository
git clone <repo-url>
cd spanstay
# 2. Start MongoDB + Redis via Docker
cd backend
docker-compose up -d mongodb redis
# 3. Install backend dependencies and start dev server
pnpm install
pnpm dev
# 4. In a new terminal, install frontend and start
cd ../vite-frontend
pnpm install
pnpm devcd backend
docker-compose up --buildEnsure MongoDB (port 27017) and Redis (port 6379) are running locally, then:
# Backend
cd backend && pnpm install && pnpm dev
# Frontend (new terminal)
cd vite-frontend && pnpm install && pnpm dev| Service | Port |
|---|---|
| Backend API | 4000 |
| Frontend Dev Server | 5173 |
| MongoDB | 27017 |
| Redis | 6379 |
| Swagger UI | http://localhost:4000/api-docs |
Base URL:
http://localhost:4000/api/v1All protected routes require the
Authorization: Bearer <accessToken>header.Interactive API documentation is available at
/api-docs(Swagger UI).
Register a new user account.
- Access: Public
- Rate Limited: Yes
Request Body:
{
"name": "Samarpan Sarkar",
"email": "samarpan@gmail.com",
"password": "Password@123"
}Responses:
| Status | Description |
|---|---|
201 |
Registration successful |
409 |
User already exists |
422 |
Validation error |
Sign in with email and password.
- Access: Public
- Rate Limited: Yes
Request Body:
{
"email": "samarpan@gmail.com",
"password": "Password@123"
}Response (200):
{
"user": {
"id": "...",
"name": "Samarpan Sarkar",
"email": "samarpan@gmail.com",
"role": "user"
},
"accessToken": "eyJ...",
"refreshToken": "eyJ..."
}Responses:
| Status | Description |
|---|---|
200 |
Signin successful |
401 |
Invalid credentials |
404 |
User not found |
Logout current user. Invalidates the stored refresh token.
- Access: Protected (any authenticated user)
Responses:
| Status | Description |
|---|---|
200 |
Logout successful |
401 |
Unauthorized |
Fetch the authenticated user's profile.
- Access: Protected
- Rate Limited: Yes
Response (200):
{
"id": "...",
"name": "Samarpan Sarkar",
"email": "samarpan@gmail.com",
"role": "user",
"createdAt": "2026-01-01T00:00:00.000Z"
}Rotate access + refresh tokens using a valid refresh token.
- Access: Public (refresh token required in body or cookie)
- Rate Limited: Yes
Responses:
| Status | Description |
|---|---|
200 |
New tokens issued |
401 |
Invalid or expired refresh token |
Register a new hotel listing with images.
- Access: Protected —
admin,hotelAdmin - Content-Type:
multipart/form-data
Form Fields:
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | ✅ | Hotel name |
description |
string | ✅ | Hotel description |
location |
string | ✅ | Hotel location |
price |
number | ✅ | Price per night (INR) |
images |
file[] | ✅ | Up to 5 images (uploaded to Cloudinary) |
amenities |
string[] | ❌ | List of amenities |
Responses:
| Status | Description |
|---|---|
201 |
Hotel registered successfully |
400 |
Validation error |
401 |
Unauthorized |
403 |
Forbidden (insufficient role) |
Fetch all hotels with optional filters, search, and pagination.
- Access: Public
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
search |
string | Full-text search across title, description, location |
location |
string | Filter by location (case-insensitive regex) |
minPrice |
number | Minimum price filter |
maxPrice |
number | Maximum price filter |
sortBy |
string | Field to sort by (e.g., price, createdAt) |
sortOrder |
string | asc or desc (default: desc) |
page |
number | Page number (default: 1) |
limit |
number | Results per page (default: 10) |
Response (200):
{
"hotels": [...],
"pagination": {
"total": 42,
"page": 1,
"limit": 10,
"totalPages": 5
}
}Results are cached in Redis for 60 seconds per unique query combination.
Fetch hotels owned by the current hotel admin.
- Access: Protected —
hotelAdmin
Fetch approval requests for hotels owned by the current hotel admin.
- Access: Protected —
hotelAdmin
Fetch a single hotel by its MongoDB ObjectId.
- Access: Public
Responses:
| Status | Description |
|---|---|
200 |
Hotel fetched successfully |
404 |
Hotel not found |
Update hotel details. Only the hotel owner can perform this action.
- Access: Protected —
admin,hotelAdmin(owner only)
Request Body (all fields optional):
{
"title": "Updated Name",
"description": "Updated description",
"location": "Mumbai",
"price": 6000
}Responses:
| Status | Description |
|---|---|
200 |
Hotel updated successfully |
401 |
Unauthorized |
403 |
Forbidden (not the owner) |
404 |
Hotel not found |
Delete a hotel. Only the hotel owner or a platform admin can delete.
- Access: Protected —
hotelAdmin(owner) oradmin
Responses:
| Status | Description |
|---|---|
200 |
Hotel deleted successfully |
401 |
Unauthorized |
403 |
Forbidden |
404 |
Hotel not found |
Create a new hotel booking.
- Access: Protected —
userrole only
Request Body:
{
"hotelId": "685f7c5f4a9d8e1a23d9a111",
"checkIn": "2026-08-10",
"checkOut": "2026-08-15",
"guests": 2
}Business Rules:
checkInmust be beforecheckOut- Conflict detection prevents double-bookings for the same hotel and date range
totalPriceis auto-calculated astotalNights × hotel.price- Initial status:
pending, paymentStatus:pending
Responses:
| Status | Description |
|---|---|
201 |
Booking created successfully |
400 |
Validation error |
401 |
Unauthorized |
404 |
Hotel not found |
409 |
Hotel already booked for selected dates |
Retrieve all bookings for the currently authenticated user.
- Access: Protected —
userrole only
Response (200):
[
{
"_id": "...",
"hotel": { "title": "...", "location": "..." },
"checkIn": "2026-08-10T00:00:00.000Z",
"checkOut": "2026-08-15T00:00:00.000Z",
"guests": 2,
"totalPrice": 22500,
"status": "pending",
"paymentStatus": "pending"
}
]Fetch all bookings for hotels owned by the current hotel admin.
- Access: Protected —
hotelAdminrole only
Cancel a booking.
- Access: Protected —
user(booking owner) orhotelAdmin(hotel owner)
Responses:
| Status | Description |
|---|---|
200 |
Booking cancelled successfully |
401 |
Unauthorized |
403 |
Forbidden |
404 |
Booking not found |
409 |
Booking already cancelled |
Confirm a booking. Must be the hotel admin who owns the booked hotel.
- Access: Protected —
hotelAdmin(hotel owner only)
Responses:
| Status | Description |
|---|---|
200 |
Booking confirmed successfully |
401 |
Unauthorized |
403 |
Forbidden |
404 |
Booking not found |
409 |
Booking already confirmed or cancelled |
Create a Stripe Checkout session for a pending booking.
- Access: Protected (booking owner only)
Response (200):
{
"sessionId": "cs_test_...",
"url": "https://checkout.stripe.com/pay/cs_test_..."
}Responses:
| Status | Description |
|---|---|
200 |
Stripe session created |
401 |
Unauthorized |
403 |
Forbidden (not the booking owner) |
404 |
Booking not found |
Stripe webhook receiver. Handles checkout.session.completed events.
- Access: Public (verified via Stripe signature)
⚠️ This endpoint requires the raw request body for Stripe signature verification. It is exempt fromexpress.json()body parsing and uses the raw buffer instead.
Behavior on checkout.session.completed:
- Sets
booking.paymentStatus = 'paid' - Sets
booking.status = 'confirmed' - Stores
booking.paymentIntentId
Verify a Stripe checkout session and check payment status.
- Access: Protected (Authenticated users)
Fetch all reviews for a specific hotel.
- Access: Public
Create a new review for a hotel.
- Access: Protected (Authenticated users)
Request Body:
{
"rating": 5,
"comment": "Excellent stay!"
}Fetch all users.
- Access: Protected —
adminrole only
Update a user's details or role.
- Access: Protected —
adminrole only
Delete a user.
- Access: Protected —
adminrole only
Fetch all pending hotel approval requests.
- Access: Protected —
adminrole only
Approve or reject a hotel request.
- Access: Protected —
adminrole only
Request Body:
{
"status": "APPROVED"
}Fetch system logs.
- Access: Protected —
adminrole only
Create a new support ticket.
- Access: Protected (Authenticated users)
Request Body:
{
"subject": "Payment issue",
"message": "I was charged twice."
}Fetch all tickets for the authenticated user.
- Access: Protected (Authenticated users)
Fetch all support tickets.
- Access: Protected —
adminrole only
Resolve a support ticket.
- Access: Protected —
adminrole only
Request Body:
{
"adminResponse": "Refund processed."
}| Field | Type | Constraints |
|---|---|---|
name |
String | Required, 3–50 chars |
email |
String | Required, unique, lowercase |
password |
String | Required, min 6 chars, select: false, bcrypt-hashed |
role |
String (enum) | user | admin | hotelAdmin, default: user |
refreshToken |
String | Stored for token rotation |
createdAt |
Date | Auto-managed |
updatedAt |
Date | Auto-managed |
| Field | Type | Constraints |
|---|---|---|
title |
String | Required |
description |
String | Required |
location |
String | Required |
price |
Number | Required (per night, INR) |
images |
Array | { url: String, publicId: String }[] |
amenities |
String[] | Optional |
owner |
ObjectId (ref: User) | Required |
createdAt |
Date | Auto-managed |
updatedAt |
Date | Auto-managed |
| Field | Type | Constraints |
|---|---|---|
user |
ObjectId (ref: User) | Required |
hotel |
ObjectId (ref: Hotel) | Required |
checkIn |
Date | Required |
checkOut |
Date | Required |
guests |
Number | Required |
totalPrice |
Number | Required, auto-calculated |
status |
String (enum) | pending | confirmed | cancelled, default: pending |
paymentStatus |
String (enum) | pending | paid | failed, default: pending |
paymentIntentId |
String | Set by Stripe webhook |
createdAt |
Date | Auto-managed |
updatedAt |
Date | Auto-managed |
| Field | Type | Constraints |
|---|---|---|
rating |
Number | Required, 1-5 |
comment |
String | Required |
user |
ObjectId (ref: User) | Required |
hotel |
ObjectId (ref: Hotel) | Required |
createdAt |
Date | Auto-managed |
updatedAt |
Date | Auto-managed |
| Field | Type | Constraints |
|---|---|---|
payload |
Object | Required |
action |
String | Required |
requestedBy |
ObjectId (ref: User) | Required |
status |
String (enum) | PENDING | APPROVED | REJECTED, default: PENDING |
hotelId |
ObjectId (ref: Hotel) | Optional |
createdAt |
Date | Auto-managed |
updatedAt |
Date | Auto-managed |
| Field | Type | Constraints |
|---|---|---|
subject |
String | Required |
message |
String | Required |
user |
ObjectId (ref: User) | Required |
status |
String (enum) | OPEN | RESOLVED, default: OPEN |
adminResponse |
String | Optional |
createdAt |
Date | Auto-managed |
updatedAt |
Date | Auto-managed |
| Field | Type | Constraints |
|---|---|---|
action |
String | Required |
target |
String | Required |
createdAt |
Date | Auto-managed |
updatedAt |
Date | Auto-managed |
SpanStay implements three user roles:
| Role | Value | Capabilities |
|---|---|---|
| User | user |
Browse hotels, create bookings, view own bookings, cancel own bookings, pay for bookings |
| Hotel Admin | hotelAdmin |
All of User + register hotels, update own hotels, delete own hotels, confirm/cancel bookings for their properties |
| Admin | admin |
All of Hotel Admin + register hotels, delete any hotel |
Request → protect (verify JWT) → authorize(ROLES.X) → Controller
protect— Verifies the Bearer token, attachesreq.userwith{ id, email, role }authorize(...roles)— Checks ifreq.user.roleis in the allowed roles list
Role assignment is done at registration time. To grant
hotelAdminoradminroles, therolefield must be set directly (or via admin tools/seeders).
SpanStay follows a modular, layered architecture:
Routes → Controller → Service → Repository → Model
| Layer | Responsibility |
|---|---|
| Routes | Define HTTP method, path, middleware chain, and delegate to controller |
| Controller | Extract request data, call service, format and send HTTP response |
| Service | Business logic — validation, authorization checks, orchestration |
| Repository | Database queries only — no business logic |
| Model | Mongoose schema, virtuals, and pre-save hooks |
This separation ensures:
- Controllers stay thin and focused on HTTP concerns
- Business rules are testable in isolation (service layer)
- Database interactions are decoupled and swappable
All errors are handled globally via error.middleware.js. Business logic throws AppError instances:
throw new AppError('Hotel not found', 404);The error middleware catches all errors, formats them consistently, and sends a JSON response.
All incoming request bodies are validated using Zod schemas via the validate middleware before reaching the controller:
router.post('/', validate(createBookingSchema), createBookingController);Redux Toolkit is used for global state:
authSlice— Stores{ user, accessToken, isAuthenticated }authSelectors— Memoized selectors for auth state
/profile → ProtectedRoute → (isAuthenticated?) → UsersProfilePage
↓ No
Redirect /signin
ProtectedRoute— Redirects unauthenticated users to/signinPublicRoute— Redirects authenticated users away from auth pages
| Hook | Purpose |
|---|---|
useAuth |
Returns current auth state from Redux |
useInitializeAuth |
Re-hydrates auth state from tokens on app load |
Forms use React Hook Form integrated with Zod resolvers (@hookform/resolvers/zod) for client-side validation that mirrors backend schemas.
| Path | Component | Access |
|---|---|---|
/ |
Home Page | Public |
/signin |
SigninPage |
Public |
/signup |
SignupPage |
Public |
/hotels |
HotelsPage |
Public |
/hotels/:id |
HotelDetailPage |
Public |
/profile |
UsersProfilePage |
Protected |
/my-bookings |
MyBookingsPage |
Protected |
/dashboard |
AdminDashboardPage |
Protected (Admin) |
/payment-success |
PaymentSuccessPage |
Protected |
/payment-cancel |
PaymentCancelPage |
Protected |
| Mechanism | Implementation |
|---|---|
| Password Hashing | bcryptjs with 10 salt rounds (pre-save hook on User model) |
| JWT Auth | Short-lived access tokens (15 min) + long-lived refresh tokens (7 days) with rotation on each refresh |
| HTTP Security Headers | helmet sets Content-Security-Policy, X-Frame-Options, etc. |
| HTTP Parameter Pollution | hpp strips duplicate query/body params |
| CORS | Configured to allow only CLIENT_URL origin with credentials |
| Rate Limiting | express-rate-limit applied to all auth endpoints |
| Request Validation | Zod schemas validate all request bodies before controller execution |
| Stripe Webhook Verification | Raw body + stripe.webhooks.constructEvent() with HMAC signature |
| Role Authorization | Middleware-level role checks on every protected resource |
Redis is used for hotel listing cache to reduce database load.
- On
GET /hotels, a cache key is generated from the full query string:hotels:{JSON.stringify(query)} - If a cached result exists → return immediately (cache hit)
- If not → query MongoDB, store result in Redis with 60-second TTL, return result
- On any create, update, or delete hotel operation →
clearHotelCache()is called to invalidate allhotels:*keys
clearHotelCache() uses Redis SCAN to find and delete all keys matching the hotels:* pattern, ensuring stale data is never served after mutations.
User creates booking (status: pending, paymentStatus: pending)
│
▼
POST /payments/checkout/:bookingId
│
▼
Stripe Checkout Session created
│
▼
User redirected to Stripe hosted payment page
│
┌────┴────┐
│ │
Success Cancel
│ │
▼ ▼
Stripe sends User redirected
webhook to PAYMENT_CANCEL_URL
│
▼
POST /payments/webhook (checkout.session.completed)
│
▼
Booking updated:
status → "confirmed"
paymentStatus → "paid"
paymentIntentId → stored
Important: The Stripe webhook endpoint must be configured in the Stripe Dashboard to point to your publicly accessible server URL (use ngrok for local development).
The docker-compose.yml in backend/ orchestrates three services:
services:
backend: # Express API on port 4000
mongodb: # MongoDB 7 on port 27017 (persistent volume)
redis: # Redis 7 Alpine on port 6379Multi-stage or single-stage Node.js image running node src/server.js on port 4000.
# Start all services
docker-compose up -d
# Start only infrastructure (run backend locally)
docker-compose up -d mongodb redis
# Rebuild and start
docker-compose up --build
# View logs
docker-compose logs -f backend
# Stop all
docker-compose down
# Stop and remove volumes
docker-compose down -v| Middleware | File | Purpose |
|---|---|---|
protect |
auth.middleware.js |
Verifies JWT Bearer token, attaches req.user |
authorize |
authorize.middleware.js |
Checks req.user.role against allowed roles |
validate |
validate.middleware.js |
Validates req.body against a Zod schema |
upload |
fileUpload.middleware.js |
Handles multipart file uploads via Multer → Cloudinary |
authLimiter |
rateLimit.middleware.js |
Rate limiting for auth endpoints |
errorHandler |
error.middleware.js |
Global error handler — formats all errors as JSON |
morgan('dev') |
(inline in app.js) | HTTP request logging |
helmet() |
(inline in app.js) | Security headers |
hpp() |
(inline in app.js) | HTTP Parameter Pollution protection |
cors() |
(inline in app.js) | Cross-Origin Resource Sharing configuration |
systemLogger |
systemLog.middleware.js |
Automatically intercepts and logs all mutating API calls (POST, PATCH, DELETE) to the database. |
SpanStay implements a robust moderation system for hotel management to ensure data quality.
- Initiation: When a
hotelAdminattempts to Create, Update, Delete, or toggle the status of a hotel, the action is intercepted. - Pending State: Instead of mutating the live database immediately (except for Creates, which are stored in a
PENDINGstate), anApprovalRequestdocument is created storing thepayload,actiontype, andrequestedByuser ID. - Admin Review: Super Admins (
adminrole) have access to a dashboard to view allPENDINGrequests, alongside nicely formatted payload details (including image previews and amenity lists). - Resolution:
- If Approved, the requested mutation is applied to the
Hotelmodel, and the request status becomesAPPROVED. - If Rejected, the request is marked
REJECTED, and the hotel remains unchanged (or is deleted if it was a rejected creation).
- If Approved, the requested mutation is applied to the
- Hotel Admins cannot submit multiple conflicting requests for the same hotel. If a request is already
PENDING, subsequent mutations are blocked with a429error. - Offline/Disabled hotels are completely hidden from public browsing but can still be discovered if explicitly searched for, rendering in a visually disabled state to prevent booking.
A built-in support ticketing system is provided to bridge communication between users and platform administrators.
- Ticket Creation: Users can create support tickets detailing their issues.
- Admin Dashboard: Super Admins view a consolidated list of all support tickets.
- Resolution: Admins can respond to tickets and mark them as
RESOLVED. - Visibility: Users can track the status of their own tickets and read admin responses directly from their profile dashboard.
SpanStay uses a modern, skeleton-based loading system to ensure smooth transitions and reduce perceived loading times.
- Unified Primitive (
Skeleton.jsx): A core pulse-animated block styled perfectly for the application's dark mode (bg-white/10 animate-pulse). - Page-Specific Skeletons: Tailored components such as
ProfileSkeleton,HotelDetailSkeleton, andReviewSkeletonprecisely mirror the layout of arriving data. - Generic Skeletons: Flexible
CardSkeletonandTableSkeletonare used universally across admin dashboards (Manage Users, Manage Approvals, etc.) and lists (Bookings, Tickets). - Spinner Replacement: All full-page and component-level legacy spinners (e.g.,
Loader2from Lucide) have been entirely removed in favor of these layout-accurate skeleton blocks.
A series of targeted enhancements and fixes were recently introduced to improve user experience, accessibility, and performance:
- Role-Based Access Control Refinements:
AdminandHotel Adminroles have been explicitly restricted from interacting withBookingWidgetcomponents and the "My Bookings" section to prevent logical conflicts with actual user workflows. - Global Scroll Restoration: Integrated a robust
ScrollToTopmechanism ensuring that all inter-page navigation immediately begins rendering from the top of the viewport. - Support Tickets UI Activation: Transformed the User Profile's Support Tickets section from a "Coming soon" placeholder into a fully functional view of the user's active tickets.
- Lighthouse Accessibility (A11y) Overhaul: Validated and bound all form
<label>tags with matchinghtmlFor/idpairs, and injected explicitaria-labeltext into all icon-only buttons (mobile menus, password toggles) to ensure seamless screen reader compatibility. - Lighthouse Performance Upgrades: Drastically improved Largest Contentful Paint (LCP) and network payloads by adding
fetchpriority="high"to main hero elements, enforcingloading="lazy"on all below-the-fold media, and dynamically requesting lower-resolution, heavily compressed images from the Cloudinary/Unsplash CDNs.
Documentation generated for SpanStay v1.0.0 — Last updated: June 2026