540 lines
20 KiB
JavaScript
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;
|
|
});
|
|
}
|