feat: add Condo owner desk frontend and backend
This commit is contained in:
415
backend/scripts/verify-deployment.mjs
Normal file
415
backend/scripts/verify-deployment.mjs
Normal file
@@ -0,0 +1,415 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import process from "node:process";
|
||||
import { createInterface } from "node:readline";
|
||||
import pg from "pg";
|
||||
|
||||
const { Client } = pg;
|
||||
const EXPECTED_DATABASE = "booking_test";
|
||||
const TARGET_SCHEMA = "condon";
|
||||
const BASELINE_FINGERPRINT =
|
||||
// Read-only baseline captured from the existing booking/finance/ingestion/
|
||||
// reporting schemas before the CONDO import. The CONDO migrations do not
|
||||
// target these schemas.
|
||||
"4b130b7f0f57eb80bb8b168fb1c11325db4c3b417e7c99cf423f9a48f0322af3";
|
||||
const MIGRATION_VERSION = "001_create_condon_schema";
|
||||
const MIGRATION_CHECKSUM =
|
||||
"7ab84c0c22fca16f75263be7291fc5c1977b46bada0794cf5b069e770d7e07a9";
|
||||
const MIGRATION_V2 = "002_legacy_import_and_bookings";
|
||||
const MIGRATION_V2_CHECKSUM =
|
||||
"cfc38024984c883490cb46ab8ab1fca2e72c67f10dc3a43c179b12a8e6f2405b";
|
||||
|
||||
const expectedColumns = {
|
||||
bookings: [
|
||||
"confirmation_no",
|
||||
"created_at"
|
||||
],
|
||||
entitlement_ledger: [
|
||||
"id",
|
||||
"entitlement_period_id",
|
||||
"usage_record_id",
|
||||
"entry_type",
|
||||
"delta_nights",
|
||||
"balance_before",
|
||||
"balance_after",
|
||||
"occurred_at"
|
||||
],
|
||||
entitlement_periods: [
|
||||
"id",
|
||||
"owner_account_id",
|
||||
"period_year",
|
||||
"period_start",
|
||||
"period_end",
|
||||
"annual_grant",
|
||||
"carry_forward",
|
||||
"current_balance",
|
||||
"row_version",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
],
|
||||
owner_accounts: [
|
||||
"id",
|
||||
"account_no",
|
||||
"transfer_date",
|
||||
"owner_name",
|
||||
"room_no",
|
||||
"purchased_room_type_code",
|
||||
"unit_no",
|
||||
"member_no",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
],
|
||||
room_types: [
|
||||
"code",
|
||||
"entitlement_tier",
|
||||
"requires_manual_multiplier",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
],
|
||||
schema_migrations: [
|
||||
"version",
|
||||
"checksum",
|
||||
"applied_at"
|
||||
],
|
||||
usage_records: [
|
||||
"id",
|
||||
"owner_account_id",
|
||||
"entitlement_period_id",
|
||||
"confirmation_no",
|
||||
"check_in",
|
||||
"check_out",
|
||||
"night_count",
|
||||
"used_room_type_code",
|
||||
"applied_multiplier",
|
||||
"rule_version",
|
||||
"use_nights",
|
||||
"balance_before",
|
||||
"balance_after",
|
||||
"remark",
|
||||
"idempotency_key",
|
||||
"created_at",
|
||||
"raw_used_room_type",
|
||||
"room_count",
|
||||
"source_sheet",
|
||||
"source_row",
|
||||
"source_sequence",
|
||||
"import_batch"
|
||||
]
|
||||
};
|
||||
|
||||
const expectedIndexes = [
|
||||
"bookings_created_idx",
|
||||
"bookings_pkey",
|
||||
"entitlement_ledger_period_occurred_idx",
|
||||
"entitlement_ledger_pkey",
|
||||
"entitlement_ledger_usage_record_key",
|
||||
"entitlement_periods_id_owner_key",
|
||||
"entitlement_periods_owner_year_key",
|
||||
"entitlement_periods_pkey",
|
||||
"owner_accounts_account_no_key",
|
||||
"owner_accounts_member_no_idx",
|
||||
"owner_accounts_pkey",
|
||||
"owner_accounts_purchased_room_type_idx",
|
||||
"owner_accounts_room_no_key",
|
||||
"room_types_pkey",
|
||||
"schema_migrations_pkey",
|
||||
"usage_records_id_period_key",
|
||||
"usage_records_idempotency_key_key",
|
||||
"usage_records_owner_created_idx",
|
||||
"usage_records_period_idx",
|
||||
"usage_records_pkey",
|
||||
"usage_records_source_location_key",
|
||||
"usage_records_used_type_check_in_idx"
|
||||
].sort();
|
||||
|
||||
const expectedRoomTypes = [
|
||||
["AC2", null, true],
|
||||
["RM1", 1, false],
|
||||
["RM2", 1, false],
|
||||
["RM3", 1, false],
|
||||
["RM4", 1, false],
|
||||
["SU1", 2, false],
|
||||
["SU2", 2, false],
|
||||
["SU3", 3, false],
|
||||
["SU6", 2, false],
|
||||
["UG1", 1, false],
|
||||
["UG2", 1, false]
|
||||
];
|
||||
|
||||
async function readStandardInput() {
|
||||
const lines = createInterface({
|
||||
input: process.stdin,
|
||||
terminal: false
|
||||
});
|
||||
for await (const line of lines) {
|
||||
lines.close();
|
||||
return line.trim();
|
||||
}
|
||||
throw Object.assign(new Error("Missing connection input"), {
|
||||
code: "MISSING_CONNECTION_INPUT"
|
||||
});
|
||||
}
|
||||
|
||||
function fingerprint(rows) {
|
||||
return createHash("sha256").update(JSON.stringify(rows)).digest("hex");
|
||||
}
|
||||
|
||||
function sameJson(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function sanitizeError(error) {
|
||||
return {
|
||||
ok: false,
|
||||
errorCode: typeof error?.code === "string" ? error.code : "VERIFY_FAILED"
|
||||
};
|
||||
}
|
||||
|
||||
let client;
|
||||
let transactionStarted = false;
|
||||
|
||||
try {
|
||||
const rawInput = await readStandardInput();
|
||||
const input = JSON.parse(rawInput);
|
||||
|
||||
client = new Client({
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
user: input.user,
|
||||
password: input.password,
|
||||
database: input.database,
|
||||
ssl: input.ssl ? { rejectUnauthorized: false } : undefined,
|
||||
application_name: "condon_readonly_verification",
|
||||
connectionTimeoutMillis: 10_000,
|
||||
query_timeout: 20_000,
|
||||
options: "-c default_transaction_read_only=on -c statement_timeout=20000 -c lock_timeout=2000"
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
await client.query("BEGIN TRANSACTION READ ONLY");
|
||||
transactionStarted = true;
|
||||
|
||||
const identityResult = await client.query(`
|
||||
SELECT
|
||||
current_database() AS database_name,
|
||||
current_user AS role_name,
|
||||
current_setting('transaction_read_only') AS transaction_read_only
|
||||
`);
|
||||
const identity = identityResult.rows[0];
|
||||
|
||||
const objectResult = await client.query(`
|
||||
SELECT
|
||||
n.nspname AS schema_name,
|
||||
c.relname AS object_name,
|
||||
c.relkind AS object_kind,
|
||||
pg_catalog.pg_get_userbyid(c.relowner) AS owner_name
|
||||
FROM pg_catalog.pg_class AS c
|
||||
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname NOT LIKE 'pg_%'
|
||||
AND n.nspname <> 'information_schema'
|
||||
AND n.nspname <> 'condon'
|
||||
ORDER BY n.nspname, c.relkind, c.relname
|
||||
`);
|
||||
const routineResult = await client.query(`
|
||||
SELECT
|
||||
n.nspname AS schema_name,
|
||||
p.proname AS routine_name,
|
||||
pg_catalog.pg_get_userbyid(p.proowner) AS owner_name,
|
||||
p.prokind AS routine_kind,
|
||||
pg_catalog.pg_get_function_identity_arguments(p.oid) AS identity_arguments
|
||||
FROM pg_catalog.pg_proc AS p
|
||||
JOIN pg_catalog.pg_namespace AS n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname NOT LIKE 'pg_%'
|
||||
AND n.nspname <> 'information_schema'
|
||||
AND n.nspname <> 'condon'
|
||||
ORDER BY n.nspname, p.proname, identity_arguments
|
||||
`);
|
||||
const existingFingerprint = fingerprint({
|
||||
objects: objectResult.rows,
|
||||
routines: routineResult.rows
|
||||
});
|
||||
|
||||
const columnResult = await client.query(`
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'condon'
|
||||
ORDER BY table_name, ordinal_position
|
||||
`);
|
||||
const actualColumns = columnResult.rows.reduce((tables, row) => {
|
||||
tables[row.table_name] ??= [];
|
||||
tables[row.table_name].push(row.column_name);
|
||||
return tables;
|
||||
}, {});
|
||||
|
||||
const indexResult = await client.query(`
|
||||
SELECT indexname AS index_name
|
||||
FROM pg_catalog.pg_indexes
|
||||
WHERE schemaname = 'condon'
|
||||
ORDER BY indexname
|
||||
`);
|
||||
const actualIndexes = indexResult.rows.map(row => row.index_name);
|
||||
|
||||
const functionResult = await client.query(`
|
||||
SELECT
|
||||
p.proname AS function_name,
|
||||
p.prosecdef AS security_definer,
|
||||
p.provolatile AS volatility,
|
||||
p.proconfig @> ARRAY['search_path=pg_catalog']::text[] AS safe_search_path,
|
||||
p.proacl IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_catalog.aclexplode(p.proacl) AS acl
|
||||
WHERE acl.grantee = 0
|
||||
AND acl.privilege_type = 'EXECUTE'
|
||||
) AS public_execute_revoked
|
||||
FROM pg_catalog.pg_proc AS p
|
||||
JOIN pg_catalog.pg_namespace AS n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'condon'
|
||||
ORDER BY p.proname
|
||||
`);
|
||||
|
||||
const constraintResult = await client.query(`
|
||||
SELECT
|
||||
count(*)::integer AS constraint_count,
|
||||
COALESCE(bool_and(
|
||||
c.confrelid = 0
|
||||
OR referenced_namespace.nspname = 'condon'
|
||||
), true) AS all_foreign_keys_internal
|
||||
FROM pg_catalog.pg_constraint AS c
|
||||
JOIN pg_catalog.pg_namespace AS own_namespace
|
||||
ON own_namespace.oid = c.connamespace
|
||||
LEFT JOIN pg_catalog.pg_class AS referenced_class
|
||||
ON referenced_class.oid = c.confrelid
|
||||
LEFT JOIN pg_catalog.pg_namespace AS referenced_namespace
|
||||
ON referenced_namespace.oid = referenced_class.relnamespace
|
||||
WHERE own_namespace.nspname = 'condon'
|
||||
`);
|
||||
|
||||
const aclResult = await client.query(`
|
||||
SELECT
|
||||
n.nspacl IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_catalog.aclexplode(n.nspacl) AS acl
|
||||
WHERE acl.grantee = 0
|
||||
) AS schema_public_access_revoked,
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_catalog.pg_class AS c
|
||||
CROSS JOIN LATERAL pg_catalog.aclexplode(
|
||||
COALESCE(c.relacl, pg_catalog.acldefault('r', c.relowner))
|
||||
) AS acl
|
||||
WHERE c.relnamespace = n.oid
|
||||
AND acl.grantee = 0
|
||||
) AS tables_have_no_public_access
|
||||
FROM pg_catalog.pg_namespace AS n
|
||||
WHERE n.nspname = 'condon'
|
||||
`);
|
||||
|
||||
const roomTypeResult = await client.query(`
|
||||
SELECT code, entitlement_tier, requires_manual_multiplier
|
||||
FROM condon.room_types
|
||||
ORDER BY code
|
||||
`);
|
||||
const actualRoomTypes = roomTypeResult.rows.map(row => [
|
||||
row.code,
|
||||
row.entitlement_tier,
|
||||
row.requires_manual_multiplier
|
||||
]);
|
||||
|
||||
const countResult = await client.query(`
|
||||
SELECT
|
||||
(SELECT count(*)::integer FROM condon.owner_accounts) AS owner_account_count,
|
||||
(SELECT count(*)::integer FROM condon.entitlement_periods) AS entitlement_period_count,
|
||||
(SELECT count(*)::integer FROM condon.usage_records) AS usage_record_count,
|
||||
(SELECT count(*)::integer FROM condon.entitlement_ledger) AS ledger_count
|
||||
`);
|
||||
const businessCounts = countResult.rows[0];
|
||||
|
||||
const migrationResult = await client.query(`
|
||||
SELECT version, checksum
|
||||
FROM condon.schema_migrations
|
||||
`);
|
||||
|
||||
const expectedFunctions = {
|
||||
calculate_multiplier: { volatility: "s" },
|
||||
create_usage_record: { volatility: "v" },
|
||||
create_usage_record_v2: { volatility: "v" },
|
||||
ensure_booking_for_usage: { volatility: "v" },
|
||||
open_entitlement_period: { volatility: "v" }
|
||||
};
|
||||
const functionsValid =
|
||||
functionResult.rowCount === 5
|
||||
&& functionResult.rows.every(row =>
|
||||
expectedFunctions[row.function_name]?.volatility === row.volatility
|
||||
&& row.security_definer === false
|
||||
&& row.safe_search_path === true
|
||||
&& row.public_execute_revoked === true
|
||||
);
|
||||
|
||||
const checks = {
|
||||
databaseVerified:
|
||||
identity.database_name === EXPECTED_DATABASE
|
||||
&& identity.role_name === input.user,
|
||||
transactionReadOnly: identity.transaction_read_only === "on",
|
||||
existingCatalogUnchanged:
|
||||
objectResult.rowCount === 128
|
||||
&& routineResult.rowCount === 5
|
||||
&& existingFingerprint === BASELINE_FINGERPRINT,
|
||||
exactTableColumns: sameJson(actualColumns, expectedColumns),
|
||||
exactIndexes: sameJson(actualIndexes, expectedIndexes),
|
||||
functionsValid,
|
||||
constraintsValid:
|
||||
constraintResult.rows[0].constraint_count === 49
|
||||
&& constraintResult.rows[0].all_foreign_keys_internal === true,
|
||||
permissionsRestricted:
|
||||
aclResult.rows[0]?.schema_public_access_revoked === true
|
||||
&& aclResult.rows[0]?.tables_have_no_public_access === true,
|
||||
exactRoomTypes: sameJson(actualRoomTypes, expectedRoomTypes),
|
||||
businessDataImported:
|
||||
businessCounts.owner_account_count === 388
|
||||
&& businessCounts.entitlement_period_count === 389
|
||||
&& businessCounts.usage_record_count === 157
|
||||
&& businessCounts.ledger_count === 547,
|
||||
migrationVerified:
|
||||
migrationResult.rowCount === 2
|
||||
&& migrationResult.rows.some(row =>
|
||||
row.version === MIGRATION_VERSION && row.checksum === MIGRATION_CHECKSUM
|
||||
)
|
||||
&& migrationResult.rows.some(row =>
|
||||
row.version === MIGRATION_V2 && row.checksum === MIGRATION_V2_CHECKSUM
|
||||
)
|
||||
};
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: Object.values(checks).every(Boolean),
|
||||
targetSchema: TARGET_SCHEMA,
|
||||
checks,
|
||||
existingCatalogFingerprint: existingFingerprint,
|
||||
targetSummary: {
|
||||
tableCount: Object.keys(actualColumns).length,
|
||||
indexCount: actualIndexes.length,
|
||||
constraintCount: constraintResult.rows[0].constraint_count,
|
||||
functionCount: functionResult.rowCount,
|
||||
roomTypeCount: roomTypeResult.rowCount,
|
||||
businessCounts
|
||||
}
|
||||
}, null, 2)}\n`);
|
||||
|
||||
if (Object.values(checks).some(result => !result)) process.exitCode = 1;
|
||||
} catch (error) {
|
||||
process.stdout.write(`${JSON.stringify(sanitizeError(error), null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (client) {
|
||||
if (transactionStarted) {
|
||||
try {
|
||||
await client.query("ROLLBACK");
|
||||
} catch {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
await client.end().catch(() => {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user