Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,26 @@
# multi-hotel-booking-system
# multi-hotel-booking-system

A minimal hotel booking API MVP covering schema, auth, room management, availability, and booking creation.

## Run

```bash
npm start
```

## Test

```bash
npm test
```

## Endpoints

- `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`
- `GET /api/hotels`, `POST /api/hotels`
- `GET /api/room-types`, `POST /api/room-types`
- `GET /api/rooms`, `POST /api/rooms`
- `GET /api/availability?roomTypeId=1&checkIn=2026-06-01&checkOut=2026-06-03&guests=2`
- `GET /api/bookings`, `POST /api/bookings`

`db/schema.sql` contains the PostgreSQL relational schema for users, hotels, room types, rooms, and bookings.
41 changes: 41 additions & 0 deletions db/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'guest',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS hotels (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
address TEXT NOT NULL DEFAULT ''
);

CREATE TABLE IF NOT EXISTS room_types (
id SERIAL PRIMARY KEY,
hotel_id INTEGER NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
name TEXT NOT NULL,
capacity INTEGER NOT NULL CHECK (capacity > 0),
nightly_rate_cents INTEGER NOT NULL CHECK (nightly_rate_cents >= 0)
);

CREATE TABLE IF NOT EXISTS rooms (
id SERIAL PRIMARY KEY,
hotel_id INTEGER NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
room_type_id INTEGER NOT NULL REFERENCES room_types(id) ON DELETE CASCADE,
room_number TEXT NOT NULL,
UNIQUE (hotel_id, room_number)
);

CREATE TABLE IF NOT EXISTS bookings (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE RESTRICT,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
CHECK (check_out > check_in)
);

CREATE INDEX IF NOT EXISTS idx_bookings_room_dates ON bookings (room_id, check_in, check_out);
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "multi-hotel-booking-system",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"start": "node src/server.js",
"test": "node --test"
}
}
64 changes: 64 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import {
createBooking,
createHotel,
createRoom,
createRoomType,
findAvailableRooms,
listBookings,
listHotels,
listRooms,
listRoomTypes,
login,
logout,
register,
} from './store.js';

const routes = {
'GET /api/hotels': () => listHotels(),
'POST /api/hotels': (body) => createHotel(body),
'GET /api/room-types': (_body, url) => listRoomTypes(url.searchParams.get('hotelId') || 1),
'POST /api/room-types': (body) => createRoomType(body),
'GET /api/rooms': (_body, url) => listRooms(url.searchParams.get('hotelId') || 1),
'POST /api/rooms': (body) => createRoom(body),
'GET /api/bookings': () => listBookings(),
'POST /api/bookings': (body) => createBooking(body),
'POST /api/auth/register': (body) => register(body),
'POST /api/auth/login': (body) => login(body),
'POST /api/auth/logout': (body) => ({ loggedOut: logout(body.token) }),
};

export function createServer() {
return http.createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && url.pathname === '/api/availability') {
return sendJson(res, { rooms: findAvailableRooms(Object.fromEntries(url.searchParams)) });
}
const handler = routes[`${req.method} ${url.pathname}`];
if (!handler) return sendJson(res, { error: 'Not found' }, 404);
const body = req.method === 'GET' ? {} : await readJson(req);
return sendJson(res, handler(body, url), req.method === 'POST' ? 201 : 200);
} catch (error) {
return sendJson(res, { error: error.message || 'Server error' }, 400);
}
});
}

async function readJson(req) {
let raw = '';
for await (const chunk of req) raw += chunk;
return raw ? JSON.parse(raw) : {};
}

function sendJson(res, payload, status = 200) {
const body = JSON.stringify(payload);
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
const port = Number(process.env.PORT || 3000);
createServer().listen(port, () => console.log(`Hotel booking API running on http://localhost:${port}`));
}
104 changes: 104 additions & 0 deletions src/store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import crypto from 'node:crypto';

let nextUserId = 2;
let nextRoomTypeId = 2;
let nextRoomId = 3;
let nextBookingId = 1;
const sessions = new Map();
const users = [{ id: 1, email: 'admin@example.com', passwordHash: hash('admin'), role: 'admin' }];
const hotels = [{ id: 1, name: 'Demo Hotel', address: '1 Main Street' }];
const roomTypes = [{ id: 1, hotelId: 1, name: 'Standard Queen', capacity: 2, nightlyRateCents: 12000 }];
const rooms = [
{ id: 1, hotelId: 1, roomTypeId: 1, roomNumber: '101' },
{ id: 2, hotelId: 1, roomTypeId: 1, roomNumber: '102' },
];
const bookings = [];

export function register({ email, password, role = 'guest' }) {
const normalizedEmail = String(email || '').trim().toLowerCase();
if (!normalizedEmail || !password) throw new Error('Email and password are required');
if (users.some((user) => user.email === normalizedEmail)) throw new Error('User already exists');
const user = { id: nextUserId++, email: normalizedEmail, passwordHash: hash(password), role };
users.push(user);
return issueSession(user);
}

export function login({ email, password }) {
const user = users.find((candidate) => candidate.email === String(email || '').trim().toLowerCase());
if (!user || user.passwordHash !== hash(password)) throw new Error('Invalid credentials');
return issueSession(user);
}

export function logout(token) {
return sessions.delete(token);
}

export function listHotels() { return hotels; }
export function getHotel(id) { return hotels.find((hotel) => hotel.id === Number(id)); }
export function createHotel(input) {
const hotel = { id: hotels.length + 1, name: required(input.name, 'Hotel name'), address: String(input.address || '') };
hotels.push(hotel);
return hotel;
}

export function listRoomTypes(hotelId = 1) { return roomTypes.filter((type) => type.hotelId === Number(hotelId)); }
export function createRoomType(input) {
const type = {
id: nextRoomTypeId++,
hotelId: Number(input.hotelId || 1),
name: required(input.name, 'Room type name'),
capacity: positiveInt(input.capacity, 'capacity'),
nightlyRateCents: nonNegativeInt(input.nightlyRateCents, 'nightlyRateCents'),
};
roomTypes.push(type);
return type;
}

export function listRooms(hotelId = 1) { return rooms.filter((room) => room.hotelId === Number(hotelId)); }
export function createRoom(input) {
const room = {
id: nextRoomId++,
hotelId: Number(input.hotelId || 1),
roomTypeId: positiveInt(input.roomTypeId, 'roomTypeId'),
roomNumber: required(input.roomNumber, 'Room number'),
};
rooms.push(room);
return room;
}

export function findAvailableRooms({ roomTypeId, checkIn, checkOut, guests = 1 }) {
validateDateRange(checkIn, checkOut);
const matchingTypes = roomTypes.filter((type) => (!roomTypeId || type.id === Number(roomTypeId)) && type.capacity >= Number(guests));
const typeIds = new Set(matchingTypes.map((type) => type.id));
return rooms.filter((room) => typeIds.has(room.roomTypeId) && isRoomAvailable(room.id, checkIn, checkOut));
}

export function createBooking(input) {
validateDateRange(input.checkIn, input.checkOut);
const available = findAvailableRooms(input);
if (available.length === 0) throw new Error('No rooms available for the selected dates');
const booking = {
id: nextBookingId++,
userId: input.userId ? Number(input.userId) : null,
roomId: available[0].id,
checkIn: input.checkIn,
checkOut: input.checkOut,
status: 'pending',
};
bookings.push(booking);
return booking;
}

export function listBookings() { return bookings; }

function isRoomAvailable(roomId, checkIn, checkOut) {
return !bookings.some((booking) => booking.roomId === roomId && booking.status !== 'cancelled' && datesOverlap(checkIn, checkOut, booking.checkIn, booking.checkOut));
}

function datesOverlap(startA, endA, startB, endB) { return startA < endB && endA > startB; }
function validateDateRange(checkIn, checkOut) { if (!checkIn || !checkOut || checkOut <= checkIn) throw new Error('checkOut must be after checkIn'); }
function required(value, name) { const text = String(value || '').trim(); if (!text) throw new Error(`${name} is required`); return text; }
function positiveInt(value, name) { const number = Number(value); if (!Number.isInteger(number) || number < 1) throw new Error(`${name} must be a positive integer`); return number; }
function nonNegativeInt(value, name) { const number = Number(value); if (!Number.isInteger(number) || number < 0) throw new Error(`${name} must be a non-negative integer`); return number; }
function hash(password) { return crypto.createHash('sha256').update(String(password)).digest('hex'); }
function issueSession(user) { const token = crypto.randomUUID(); sessions.set(token, { userId: user.id, role: user.role }); return { token, user: { id: user.id, email: user.email, role: user.role } }; }
42 changes: 42 additions & 0 deletions test/hotel.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createServer } from '../src/server.js';

test('auth, availability, and booking flow', async () => {
const server = createServer().listen(0);
const base = `http://127.0.0.1:${server.address().port}`;
try {
const authResponse = await post(`${base}/api/auth/register`, { email: 'guest@example.com', password: 'secret' });
assert.ok(authResponse.token);

let response = await fetch(`${base}/api/availability?roomTypeId=1&checkIn=2026-06-01&checkOut=2026-06-03&guests=2`);
let data = await response.json();
assert.equal(data.rooms.length, 2);

const booking = await post(`${base}/api/bookings`, { roomTypeId: 1, checkIn: '2026-06-01', checkOut: '2026-06-03', guests: 2, userId: authResponse.user.id });
assert.equal(booking.status, 'pending');

response = await fetch(`${base}/api/availability?roomTypeId=1&checkIn=2026-06-01&checkOut=2026-06-03&guests=2`);
data = await response.json();
assert.equal(data.rooms.length, 1);
} finally {
server.close();
}
});

test('admin endpoints create room types and rooms', async () => {
const server = createServer().listen(0);
const base = `http://127.0.0.1:${server.address().port}`;
try {
const roomType = await post(`${base}/api/room-types`, { hotelId: 1, name: 'Suite', capacity: 4, nightlyRateCents: 24000 });
const room = await post(`${base}/api/rooms`, { hotelId: 1, roomTypeId: roomType.id, roomNumber: '201' });
assert.equal(room.roomTypeId, roomType.id);
} finally {
server.close();
}
});

async function post(url, body) {
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return response.json();
}