import { randomBytes, randomUUID } from "node:crypto"; import http from "node:http"; const emptyScenario = process.argv.includes("--empty"); const roomTypes = [ ["RM1", 1], ["RM2", 1], ["RM3", 1], ["RM4", 1], ["UG1", 1], ["UG2", 1], ["SU1", 2], ["SU2", 2], ["SU6", 2], ["SU3", 3], ["AC2", null] ].map(([code, entitlementTier]) => ({ code, entitlementTier, requiresManualMultiplier: entitlementTier === null })); const owners = emptyScenario ? [] : [ { id: "00000000-0000-4000-8000-000000000101", accountNo: 101, transferDate: "2024-01-08", name: "API Test Owner", roomNo: "A101", purchasedRoomType: "RM1", unitNo: "UNIT-A101", memberNo: "M101", remainingStayPrivileges: 13 }, { id: "00000000-0000-4000-8000-000000000102", accountNo: null, transferDate: null, name: "Nullable API Owner", roomNo: "A102", purchasedRoomType: "AC2", unitNo: "UNIT-A102", memberNo: "M102", remainingStayPrivileges: 15 } ]; let usageRecords = emptyScenario ? [] : [ { id: "00000000-0000-4000-8000-000000000201", confirmationNo: "26070001", ownerAccountId: owners[0].id, ownerName: owners[0].name, ownerRoomNo: owners[0].roomNo, checkIn: "2026-07-10", checkOut: "2026-07-12", night: 2, use: 2, balance: 13, usedRoomType: "RM1", remark: "Mock API record", appliedMultiplier: 1, createdAt: "2026-07-12T08:00:00.000Z" } ]; const idempotentResponses = new Map(); const sessions = new Map(); const sessionTtlMs = 12 * 60 * 60 * 1_000; const authUsername = "wyndhamcondon"; const authPassword = "wyndhamcondon"; function send(response, status, payload, headers = {}) { response.writeHead(status, { "Access-Control-Allow-Origin": "http://127.0.0.1:4173", "Access-Control-Allow-Credentials": "true", "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...headers }); response.end(JSON.stringify(payload)); } function sessionToken(request) { const cookie = request.headers.cookie || ""; for (const part of cookie.split(";")) { const [name, value] = part.trim().split("=", 2); if (name === "condon_session" && value) return value; } return null; } function activeSession(request) { const token = sessionToken(request); if (!token) return null; const session = sessions.get(token); if (!session) return null; if (session.expiresAtMs <= Date.now()) { sessions.delete(token); return null; } return session; } function paginated(items, url) { const page = Math.max(1, Number(url.searchParams.get("page")) || 1); const pageSize = Math.min(100, Math.max(1, Number(url.searchParams.get("pageSize")) || 25)); const start = (page - 1) * pageSize; return { items: items.slice(start, start + pageSize), total: items.length, page, pageSize }; } function tier(code) { return roomTypes.find(roomType => roomType.code === code)?.entitlementTier ?? null; } function dashboard() { const purchasedCounts = new Map(); const usedCounts = new Map(); const monthlyCounts = new Map(); owners.forEach(owner => purchasedCounts.set(owner.purchasedRoomType, (purchasedCounts.get(owner.purchasedRoomType) || 0) + 1)); usageRecords.forEach(record => { usedCounts.set(record.usedRoomType, (usedCounts.get(record.usedRoomType) || 0) + record.night); const month = Number(record.checkIn.slice(5, 7)); monthlyCounts.set(month, (monthlyCounts.get(month) || 0) + record.use); }); return { periodYear: 2026, ownerRooms: owners.length, remainingPrivileges: owners.reduce((total, owner) => total + (owner.remainingStayPrivileges || 0), 0), used: usageRecords.reduce((total, record) => total + record.use, 0), purchasedRoomTypes: [...purchasedCounts].map(([roomType, count]) => ({ roomType, count })), usedRoomTypes: [...usedCounts].map(([roomType, count]) => ({ roomType, count })), monthlyUse: [...monthlyCounts].map(([month, use]) => ({ month, use })) }; } async function readJson(request) { const chunks = []; for await (const chunk of request) chunks.push(chunk); return JSON.parse(Buffer.concat(chunks).toString("utf8")); } const server = http.createServer(async (request, response) => { if (request.method === "OPTIONS") { response.writeHead(204, { "Access-Control-Allow-Origin": "http://127.0.0.1:4173", "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Methods": "GET,POST,OPTIONS" }); response.end(); return; } const url = new URL(request.url || "/", "http://127.0.0.1:3000"); if (request.method === "GET" && url.pathname === "/auth/session") { const session = activeSession(request); send(response, 200, session ? { authenticated: true, user: { username: session.username }, expiresAt: new Date(session.expiresAtMs).toISOString() } : { authenticated: false }); return; } if (request.method === "POST" && url.pathname === "/auth/login") { try { const input = await readJson(request); if (input.username !== authUsername || input.password !== authPassword) { send(response, 401, { error: { code: "INVALID_CREDENTIALS", message: "Invalid username or password" } }); return; } const token = randomBytes(32).toString("base64url"); const expiresAtMs = Date.now() + sessionTtlMs; sessions.set(token, { username: authUsername, expiresAtMs }); send(response, 200, { authenticated: true, user: { username: authUsername }, expiresAt: new Date(expiresAtMs).toISOString() }, { "Set-Cookie": `condon_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200` }); } catch { send(response, 400, { error: { code: "VALIDATION_ERROR", message: "Invalid JSON" } }); } return; } if (request.method === "POST" && url.pathname === "/auth/logout") { const token = sessionToken(request); if (token) sessions.delete(token); send(response, 200, { authenticated: false }, { "Set-Cookie": "condon_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" }); return; } if (!activeSession(request)) { send(response, 401, { error: { code: "UNAUTHORIZED", message: "Authentication required" } }); return; } if (request.method === "GET" && url.pathname === "/health") { send(response, 200, { status: "ok", database: "booking_test", schema: "condon", migrationVersion: "001" }); return; } if (request.method === "GET" && url.pathname === "/room-types") { send(response, 200, roomTypes); return; } if (request.method === "GET" && url.pathname === "/owner-accounts") { const q = (url.searchParams.get("q") || "").toLowerCase(); const roomType = url.searchParams.get("roomType") || ""; const filtered = owners.filter(owner => { const searchable = `${owner.accountNo ?? ""} ${owner.name} ${owner.roomNo} ${owner.unitNo} ${owner.memberNo}`.toLowerCase(); return (!q || searchable.includes(q)) && (!roomType || owner.purchasedRoomType === roomType); }); send(response, 200, { ...paginated(filtered, url), periodYear: 2026 }); return; } if (request.method === "GET" && url.pathname.startsWith("/owner-accounts/")) { const owner = owners.find(item => item.id === decodeURIComponent(url.pathname.slice("/owner-accounts/".length))); send(response, owner ? 200 : 404, owner || { error: { code: "NOT_FOUND", message: "Owner account not found" } }); return; } if (request.method === "GET" && url.pathname === "/usage-records") { const ownerAccountId = url.searchParams.get("ownerAccountId") || ""; const confirmationNo = url.searchParams.get("confirmationNo") || ""; const usedRoomType = url.searchParams.get("usedRoomType") || ""; const filtered = usageRecords.filter(record => ( (!ownerAccountId || record.ownerAccountId === ownerAccountId) && (!confirmationNo || record.confirmationNo.includes(confirmationNo)) && (!usedRoomType || record.usedRoomType === usedRoomType) )); send(response, 200, paginated(filtered, url)); return; } if (request.method === "GET" && url.pathname === "/dashboard") { send(response, 200, dashboard()); return; } if (request.method === "POST" && url.pathname === "/usage-records") { try { const input = await readJson(request); if (idempotentResponses.has(input.idempotencyKey)) { send(response, 201, idempotentResponses.get(input.idempotencyKey)); return; } const owner = owners.find(item => item.id === input.ownerAccountId); if (!owner) { send(response, 404, { error: { code: "NOT_FOUND", message: "Owner account not found" } }); return; } if (usageRecords.some(record => record.confirmationNo === input.confirmationNo)) { send(response, 409, { error: { code: "CONFLICT", message: "Confirmation already exists" } }); return; } const start = Date.parse(`${input.checkIn}T00:00:00Z`); const end = Date.parse(`${input.checkOut}T00:00:00Z`); const night = Math.round((end - start) / 86_400_000); const purchasedTier = tier(owner.purchasedRoomType); const usedTier = tier(input.usedRoomType); const multiplier = purchasedTier === null || usedTier === null ? Number(input.manualMultiplier) : Math.max(1, usedTier - purchasedTier + 1); const use = night * multiplier; if (!Number.isInteger(night) || night <= 0 || !Number.isInteger(multiplier) || multiplier < 1 || multiplier > 3) { send(response, 400, { error: { code: "BUSINESS_RULE_VIOLATION", message: "Invalid usage rule" } }); return; } if (use > owner.remainingStayPrivileges) { send(response, 422, { error: { code: "INSUFFICIENT_BALANCE", message: "Insufficient balance" } }); return; } owner.remainingStayPrivileges -= use; const created = { id: randomUUID(), confirmationNo: input.confirmationNo, ownerAccountId: owner.id, ownerName: owner.name, ownerRoomNo: owner.roomNo, checkIn: input.checkIn, checkOut: input.checkOut, night, use, balance: owner.remainingStayPrivileges, usedRoomType: input.usedRoomType, remark: input.remark || "", appliedMultiplier: multiplier, createdAt: new Date().toISOString() }; usageRecords = [created, ...usageRecords]; idempotentResponses.set(input.idempotencyKey, created); send(response, 201, created); } catch { send(response, 400, { error: { code: "VALIDATION_ERROR", message: "Invalid JSON" } }); } return; } send(response, 404, { error: { code: "NOT_FOUND", message: "Route not found" } }); }); server.listen(3000, "127.0.0.1", () => { process.stdout.write(`MOCK_API_READY http://127.0.0.1:3000 scenario=${emptyScenario ? "empty" : "sample"}\n`); }); for (const signal of ["SIGINT", "SIGTERM"]) { process.once(signal, () => server.close(() => process.exit(0))); }