import { createHash, randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { buildApp } from "../dist/src/app.js"; import { ApiError } from "../dist/src/errors.js"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(scriptDirectory, "../.."); const defaultBatchDirectory = path.join( projectRoot, ".planning/data_import_audit/import_batch_2026_07_31" ); const expectedSourceSha256 = "016dc36d15cc40a5004c49403a52ab0df24d0a38d27a867d414884e2a9592c3a"; const expectedImport = Object.freeze({ owners: 388, usage: 157, used2026: 438, remaining2026: 5394 }); const importTimestamp = Date.UTC(2026, 6, 31, 12, 0, 0); const roomTypes = Object.freeze([ ["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]) => Object.freeze({ code, entitlementTier, requiresManualMultiplier: entitlementTier === null }))); function stableUuid(namespace, value) { const bytes = createHash("sha256").update(`${namespace}:${value}`).digest().subarray(0, 16); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = bytes.toString("hex"); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } function sourceOrder(left, right) { const sheetDifference = String(left.source_sheet).localeCompare(String(right.source_sheet)); if (sheetDifference !== 0) return sheetDifference; const sequenceDifference = Number(left.source_sequence) - Number(right.source_sequence); if (sequenceDifference !== 0) return sequenceDifference; return Number(left.source_row) - Number(right.source_row); } function paginate(items, query) { const start = (query.page - 1) * query.pageSize; return { items: items.slice(start, start + query.pageSize), total: items.length, page: query.page, pageSize: query.pageSize }; } function periodKey(ownerId, year) { return `${ownerId}|${year}`; } function lower(value) { return String(value ?? "").toLocaleLowerCase("en"); } function inputsMatch(left, right) { return left.ownerAccountId === right.ownerAccountId && left.confirmationNo === right.confirmationNo && left.checkIn === right.checkIn && left.checkOut === right.checkOut && left.usedRoomType === right.usedRoomType && left.manualMultiplier === right.manualMultiplier && left.remark === right.remark; } function safeIntegerEnvironment(name, fallback, minimum, maximum) { const raw = process.env[name]; if (raw === undefined || raw === "") return fallback; const value = Number(raw); if (!Number.isInteger(value) || value < minimum || value > maximum) { throw Object.assign(new Error(`Invalid ${name}`), { code: "INVALID_PREVIEW_CONFIGURATION" }); } return value; } async function readBatch(batchDirectory) { const read = async fileName => JSON.parse( await readFile(path.join(batchDirectory, fileName), "utf8") ); const [manifest, owners, usage, rejected] = await Promise.all([ read("manifest.json"), read("owners.json"), read("usage_legacy.json"), read("rejected_rows.json") ]); return { manifest, owners, usage, rejected }; } function assertVerifiedBatch(batch) { const { manifest, owners, usage, rejected } = batch; const issueKeys = [ "arithmetic_issues", "balance_chain_issues", "date_night_issues", "missing_owner_rooms", "owner_issues", "required_usage_issues" ]; const hasIssues = issueKeys.some(key => !Array.isArray(manifest.checks?.[key]) || manifest.checks[key].length > 0); const useSum = usage.reduce((sum, record) => sum + Number(record.use), 0); if (manifest.mode !== "local-import-preflight" || manifest.source_sha256 !== expectedSourceSha256 || owners.length !== expectedImport.owners || usage.length !== expectedImport.usage || owners.length !== manifest.counts?.owner_count || usage.length !== manifest.counts?.accepted_usage_count || rejected.length !== manifest.counts?.rejected_row_count || useSum !== manifest.counts?.usage_use_sum || manifest.checks?.room_gt_one_count !== 0 || hasIssues) { throw Object.assign(new Error("The retained import snapshot failed verification"), { code: "INVALID_IMPORT_SNAPSHOT" }); } } export class ImportSnapshotRepository { static async load(batchDirectory = defaultBatchDirectory) { const batch = await readBatch(batchDirectory); assertVerifiedBatch(batch); const repository = new ImportSnapshotRepository(batch); const diagnostics = await repository.diagnostics(); if (diagnostics.owners !== expectedImport.owners || diagnostics.usage !== expectedImport.usage || diagnostics.used2026 !== expectedImport.used2026 || diagnostics.remaining2026 !== expectedImport.remaining2026) { throw Object.assign(new Error("The import snapshot does not match the verified database totals"), { code: "IMPORT_SNAPSHOT_TOTAL_MISMATCH" }); } return repository; } constructor(batch) { this.manifest = batch.manifest; this.ownerIdsByRoom = new Map(); this.owners = batch.owners.map(record => { const id = stableUuid("owner", record.room_no); this.ownerIdsByRoom.set(record.room_no, id); return { id, accountNo: record.account_no ?? null, transferDate: record.transfer_date || null, name: String(record.name), roomNo: String(record.room_no), purchasedRoomType: String(record.purchased_room_type_code), unitNo: String(record.unit_no), memberNo: String(record.member_no) }; }).sort((left, right) => ( (left.accountNo ?? Number.MAX_SAFE_INTEGER) - (right.accountNo ?? Number.MAX_SAFE_INTEGER) || left.roomNo.localeCompare(right.roomNo) || left.id.localeCompare(right.id) )); this.ownerById = new Map(this.owners.map(owner => [owner.id, owner])); this.periodBalances = new Map(); this.periodInitialBalances = new Map(); this.periodYears = new Set(); this.idempotentResponses = new Map(); const orderedUsage = [...batch.usage].sort(sourceOrder); const usageByRoomAndYear = new Map(); for (const record of orderedUsage) { const key = `${record.owner_room_no}|${record.period_year}`; const records = usageByRoomAndYear.get(key) ?? []; records.push(record); usageByRoomAndYear.set(key, records); } for (const owner of this.owners) { const room = owner.roomNo; const rows2025 = usageByRoomAndYear.get(`${room}|2025`) ?? []; const carryForward = rows2025.length > 0 ? Number(rows2025.at(-1).balance) : 0; this.periodYears.add(periodKey(owner.id, 2026)); this.periodInitialBalances.set(periodKey(owner.id, 2026), 15 + carryForward); const used2026 = (usageByRoomAndYear.get(`${room}|2026`) ?? []) .reduce((sum, record) => sum + Number(record.use), 0); this.periodBalances.set(periodKey(owner.id, 2026), 15 + carryForward - used2026); const otherYears = new Set( orderedUsage .filter(record => record.owner_room_no === room && Number(record.period_year) !== 2026) .map(record => Number(record.period_year)) ); for (const year of otherYears) { const rows = usageByRoomAndYear.get(`${room}|${year}`) ?? []; this.periodYears.add(periodKey(owner.id, year)); this.periodInitialBalances.set(periodKey(owner.id, year), 15); this.periodBalances.set( periodKey(owner.id, year), 15 - rows.reduce((sum, record) => sum + Number(record.use), 0) ); } } this.usageEntries = orderedUsage.map((record, index) => { const ownerAccountId = this.ownerIdsByRoom.get(record.owner_room_no); const owner = this.ownerById.get(ownerAccountId); if (!owner) { throw Object.assign(new Error("Usage owner is missing from the snapshot"), { code: "IMPORT_SNAPSHOT_OWNER_MISSING" }); } return { periodYear: Number(record.period_year), sourceSheet: String(record.source_sheet), sourceSequence: Number(record.source_sequence), sourceRow: Number(record.source_row), canonicalUsedRoomType: record.canonical_used_room_type_code || null, publicRecord: { id: stableUuid("usage", `${record.source_sheet}:${record.source_row}:${record.source_sequence}`), confirmationNo: String(record.confirmation_no), ownerAccountId, ownerName: owner.name, ownerRoomNo: owner.roomNo, checkIn: String(record.check_in), checkOut: String(record.check_out), night: Number(record.night), use: Number(record.use), balance: Number(record.balance), usedRoomType: String(record.canonical_used_room_type_code || record.raw_used_room_type || ""), remark: String(record.remark ?? ""), appliedMultiplier: null, createdAt: new Date(importTimestamp + (orderedUsage.length - index) * 1_000).toISOString() } }; }); } async diagnostics() { const dashboard = await this.getDashboard(2026); return { sourceSha256: this.manifest.source_sha256, owners: this.owners.length, usage: this.usageEntries.length, used2026: dashboard.used, remaining2026: dashboard.remainingPrivileges }; } async health() { return { status: "ok", database: "booking_test", schema: "condon", migrationVersion: "003_delete_usage_record:snapshot" }; } async listRoomTypes() { return roomTypes.map(roomType => ({ ...roomType })); } ownerForYear(owner, periodYear) { const key = periodKey(owner.id, periodYear); return { ...owner, remainingStayPrivileges: this.periodYears.has(key) ? this.periodBalances.get(key) : null }; } async listOwnerAccounts(query) { const needle = lower(query.query); const filtered = this.owners.filter(owner => { const searchable = lower([ owner.accountNo ?? "", owner.name, owner.roomNo, owner.unitNo, owner.memberNo ].join(" ")); return (!needle || searchable.includes(needle)) && (!query.roomType || owner.purchasedRoomType === query.roomType); }); return { ...paginate(filtered.map(owner => this.ownerForYear(owner, query.periodYear)), query), periodYear: query.periodYear }; } async getOwnerAccount(id, periodYear) { const owner = this.ownerById.get(id); return owner ? this.ownerForYear(owner, periodYear) : null; } async listUsageRecords(query) { const filtered = this.usageEntries.filter(entry => ( (!query.ownerAccountId || entry.publicRecord.ownerAccountId === query.ownerAccountId) && (!query.confirmationNo || entry.publicRecord.confirmationNo.includes(query.confirmationNo)) && (!query.usedRoomType || entry.canonicalUsedRoomType === query.usedRoomType) )); return paginate(filtered.map(entry => ({ ...entry.publicRecord })), query); } rebuildPeriodBalance(ownerAccountId, periodYear) { const key = periodKey(ownerAccountId, periodYear); const entries = this.usageEntries .filter(entry => entry.publicRecord.ownerAccountId === ownerAccountId && entry.periodYear === periodYear) .sort((left, right) => { const leftImported = left.sourceSheet !== null; const rightImported = right.sourceSheet !== null; if (leftImported !== rightImported) return leftImported ? -1 : 1; if (leftImported) return sourceOrder(left, right); return String(left.publicRecord.createdAt).localeCompare(String(right.publicRecord.createdAt)) || left.publicRecord.id.localeCompare(right.publicRecord.id); }); let balance = this.periodInitialBalances.get(key) ?? 15; for (const entry of entries) { balance -= entry.publicRecord.use; entry.publicRecord.balance = balance; } this.periodBalances.set(key, balance); } async deleteUsageRecord(id) { const index = this.usageEntries.findIndex(entry => entry.publicRecord.id === id); if (index < 0) { throw new ApiError(404, "NOT_FOUND", "The requested business record was not found"); } const [removed] = this.usageEntries.splice(index, 1); for (const [key, value] of this.idempotentResponses) { if (value.response.id === id) this.idempotentResponses.delete(key); } this.rebuildPeriodBalance(removed.publicRecord.ownerAccountId, removed.periodYear); } async createUsageRecord(input) { const previous = this.idempotentResponses.get(input.idempotencyKey); if (previous) { if (!inputsMatch(previous.input, input)) { throw new ApiError(409, "CONFLICT", "The request conflicts with current data"); } return { ...previous.response }; } const owner = this.ownerById.get(input.ownerAccountId); if (!owner) { throw new ApiError(404, "NOT_FOUND", "The requested business record was not found"); } const purchased = roomTypes.find(roomType => roomType.code === owner.purchasedRoomType); const used = roomTypes.find(roomType => roomType.code === input.usedRoomType); if (!purchased || !used) { throw new ApiError(404, "NOT_FOUND", "The requested business record was not found"); } const checkInMs = Date.parse(`${input.checkIn}T00:00:00Z`); const checkOutMs = Date.parse(`${input.checkOut}T00:00:00Z`); const night = Math.round((checkOutMs - checkInMs) / 86_400_000); if (!Number.isInteger(night) || night <= 0) { throw new ApiError(400, "BUSINESS_RULE_VIOLATION", "The request violates a business rule"); } const manualRequired = purchased.entitlementTier === null || used.entitlementTier === null; if (manualRequired !== (input.manualMultiplier !== null)) { throw new ApiError(400, "BUSINESS_RULE_VIOLATION", "The request violates a business rule"); } const appliedMultiplier = manualRequired ? input.manualMultiplier : Math.max(1, used.entitlementTier - purchased.entitlementTier + 1); const periodYear = Number(input.checkIn.slice(0, 4)); const key = periodKey(owner.id, periodYear); if (!this.periodYears.has(key)) { throw new ApiError(404, "NOT_FOUND", "The requested business record was not found"); } if (!input.checkOut.startsWith(`${periodYear}-`) || input.checkOut > `${periodYear}-12-31`) { throw new ApiError(400, "BUSINESS_RULE_VIOLATION", "The request violates a business rule"); } const use = night * appliedMultiplier; const balanceBefore = this.periodBalances.get(key); if (use > balanceBefore) { throw new ApiError( 422, "INSUFFICIENT_BALANCE", "The owner account does not have enough stay privileges" ); } const balance = balanceBefore - use; this.periodBalances.set(key, balance); const response = { id: randomUUID(), confirmationNo: input.confirmationNo, ownerAccountId: owner.id, ownerName: owner.name, ownerRoomNo: owner.roomNo, checkIn: input.checkIn, checkOut: input.checkOut, night, use, balance, usedRoomType: input.usedRoomType, remark: input.remark, appliedMultiplier, createdAt: new Date().toISOString() }; this.usageEntries.push({ periodYear, sourceSheet: null, sourceSequence: null, sourceRow: null, canonicalUsedRoomType: input.usedRoomType, publicRecord: response }); this.idempotentResponses.set(input.idempotencyKey, { input: { ...input }, response: { ...response } }); return { ...response }; } async getDashboard(periodYear) { const purchasedCounts = new Map(); for (const owner of this.owners) { purchasedCounts.set( owner.purchasedRoomType, (purchasedCounts.get(owner.purchasedRoomType) ?? 0) + 1 ); } const yearEntries = this.usageEntries.filter(entry => entry.periodYear === periodYear); const usedCounts = new Map(); const monthlyCounts = new Map(); for (const entry of yearEntries) { const roomType = entry.publicRecord.usedRoomType; usedCounts.set(roomType, (usedCounts.get(roomType) ?? 0) + entry.publicRecord.night); const month = Number(entry.publicRecord.checkIn.slice(5, 7)); monthlyCounts.set(month, (monthlyCounts.get(month) ?? 0) + entry.publicRecord.use); } let remainingPrivileges = 0; for (const owner of this.owners) { const key = periodKey(owner.id, periodYear); if (this.periodYears.has(key)) remainingPrivileges += this.periodBalances.get(key); } return { periodYear, ownerRooms: this.owners.length, remainingPrivileges, used: yearEntries.reduce((sum, entry) => sum + entry.publicRecord.use, 0), purchasedRoomTypes: [...purchasedCounts] .sort(([left], [right]) => left.localeCompare(right)) .map(([roomType, count]) => ({ roomType, count })), usedRoomTypes: [...usedCounts] .sort(([left], [right]) => left.localeCompare(right)) .map(([roomType, count]) => ({ roomType, count })), monthlyUse: [...monthlyCounts] .sort(([left], [right]) => left - right) .map(([month, use]) => ({ month, use })) }; } async close() {} } export async function startImportPreview({ batchDirectory = process.env.CONDO_IMPORT_SNAPSHOT_DIR || defaultBatchDirectory, host = process.env.API_HOST || "127.0.0.1", port = safeIntegerEnvironment("API_PORT", 3000, 1, 65535) } = {}) { const repository = await ImportSnapshotRepository.load(path.resolve(batchDirectory)); const corsOrigins = (process.env.CORS_ORIGINS || "http://127.0.0.1:4173,http://localhost:4173") .split(",") .map(origin => origin.trim()) .filter(Boolean); const app = await buildApp({ repository, corsOrigins, authentication: { username: process.env.AUTH_USERNAME || "wyndhamcondon", password: process.env.AUTH_PASSWORD || "wyndhamcondon", sessionTtlMs: safeIntegerEnvironment("AUTH_SESSION_TTL_HOURS", 12, 1, 168) * 60 * 60 * 1_000, cookieSecure: process.env.AUTH_COOKIE_SECURE === "true" }, logger: false }); await app.listen({ host, port }); return { app, repository, host, port }; } async function main() { const batchDirectory = path.resolve( process.env.CONDO_IMPORT_SNAPSHOT_DIR || defaultBatchDirectory ); const repository = await ImportSnapshotRepository.load(batchDirectory); const diagnostics = await repository.diagnostics(); if (process.argv.includes("--check")) { process.stdout.write(`${JSON.stringify({ ok: true, mode: "verified-import-snapshot", ...diagnostics }, null, 2)}\n`); return; } const preview = await startImportPreview({ batchDirectory }); process.stdout.write(`${JSON.stringify({ event: "IMPORT_PREVIEW_READY", url: `http://${preview.host}:${preview.port}`, owners: diagnostics.owners, usage: diagnostics.usage, used2026: diagnostics.used2026, remaining2026: diagnostics.remaining2026 })}\n`); let shuttingDown = false; const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; await preview.app.close(); }; process.once("SIGINT", () => { void shutdown(); }); process.once("SIGTERM", () => { void shutdown(); }); } const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; if (isMain) { main().catch(error => { process.stderr.write(`${JSON.stringify({ event: "IMPORT_PREVIEW_FAILED", errorCode: typeof error?.code === "string" ? error.code : "IMPORT_PREVIEW_FAILED" })}\n`); process.exitCode = 1; }); }