From 8a0d5b25275eefa6a91bf0e2672dee606bc4b4a0 Mon Sep 17 00:00:00 2001 From: dukhyun Date: Mon, 11 May 2026 09:13:57 +0900 Subject: [PATCH] feat: implement hotel booking mvp --- README.md | 27 +++++++++++- db/schema.sql | 41 ++++++++++++++++++ package.json | 10 +++++ src/server.js | 64 ++++++++++++++++++++++++++++ src/store.js | 104 +++++++++++++++++++++++++++++++++++++++++++++ test/hotel.test.js | 42 ++++++++++++++++++ 6 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 db/schema.sql create mode 100644 package.json create mode 100644 src/server.js create mode 100644 src/store.js create mode 100644 test/hotel.test.js diff --git a/README.md b/README.md index fc3694a..7d16276 100644 --- a/README.md +++ b/README.md @@ -1 +1,26 @@ -# multi-hotel-booking-system \ No newline at end of file +# 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. diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..3cb04d2 --- /dev/null +++ b/db/schema.sql @@ -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); diff --git a/package.json b/package.json new file mode 100644 index 0000000..588168e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..b4a099c --- /dev/null +++ b/src/server.js @@ -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}`)); +} diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..72966d5 --- /dev/null +++ b/src/store.js @@ -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 } }; } diff --git a/test/hotel.test.js b/test/hotel.test.js new file mode 100644 index 0000000..fe9f1a0 --- /dev/null +++ b/test/hotel.test.js @@ -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(); +}