Files
wyndham-Condon/backend/scripts/import-preview-server.mjs
2026-08-03 16:43:57 +08:00

540 lines
20 KiB
JavaScript

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;
});
}