feat: add Condo owner desk frontend and backend
This commit is contained in:
504
backend/scripts/import-preview-server.mjs
Normal file
504
backend/scripts/import-preview-server.mjs
Normal file
@@ -0,0 +1,504 @@
|
||||
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.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));
|
||||
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.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: "002_legacy_import_and_bookings: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);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user