feat: manage owner usage history records
This commit is contained in:
@@ -26,12 +26,24 @@ const legacyDownPath = path.join(
|
||||
"migrations",
|
||||
"002_legacy_import_and_bookings.down.sql"
|
||||
);
|
||||
const deleteUpPath = path.join(
|
||||
backendDirectory,
|
||||
"migrations",
|
||||
"003_delete_usage_record.up.sql"
|
||||
);
|
||||
const deleteDownPath = path.join(
|
||||
backendDirectory,
|
||||
"migrations",
|
||||
"003_delete_usage_record.down.sql"
|
||||
);
|
||||
|
||||
const [upSql, downSql, legacyUpSql, legacyDownSql] = await Promise.all([
|
||||
const [upSql, downSql, legacyUpSql, legacyDownSql, deleteUpSql, deleteDownSql] = await Promise.all([
|
||||
readFile(upPath, "utf8"),
|
||||
readFile(downPath, "utf8"),
|
||||
readFile(legacyUpPath, "utf8"),
|
||||
readFile(legacyDownPath, "utf8")
|
||||
readFile(legacyDownPath, "utf8"),
|
||||
readFile(deleteUpPath, "utf8"),
|
||||
readFile(deleteDownPath, "utf8")
|
||||
]);
|
||||
|
||||
const checks = [];
|
||||
@@ -166,6 +178,21 @@ record(
|
||||
&& /^DROP FUNCTION condon\.create_usage_record_v2/gm.test(legacyDownSql)
|
||||
&& /^DROP INDEX condon\.usage_records_source_location_key;$/gm.test(legacyDownSql)
|
||||
);
|
||||
record(
|
||||
"delete migration is an internal transactional function",
|
||||
/^CREATE FUNCTION condon\.delete_usage_record\(\s*p_usage_record_id uuid\s*\)/m.test(deleteUpSql)
|
||||
&& /^RETURNS void$/m.test(deleteUpSql)
|
||||
&& /^SECURITY INVOKER$/m.test(deleteUpSql)
|
||||
&& deleteUpSql.includes("FOR UPDATE")
|
||||
&& deleteUpSql.includes("DELETE FROM condon.entitlement_ledger")
|
||||
&& deleteUpSql.includes("UPDATE condon.entitlement_periods")
|
||||
&& /^REVOKE ALL ON FUNCTION condon\.delete_usage_record\(uuid\) FROM PUBLIC;$/m.test(deleteUpSql)
|
||||
);
|
||||
record(
|
||||
"delete migration down reverses its function",
|
||||
/^DROP FUNCTION condon\.delete_usage_record\(uuid\);$/m.test(deleteDownSql)
|
||||
&& !/\bCASCADE\b/i.test(deleteDownSql)
|
||||
);
|
||||
|
||||
for (const check of checks) {
|
||||
process.stdout.write(
|
||||
|
||||
@@ -166,6 +166,7 @@ export class ImportSnapshotRepository {
|
||||
));
|
||||
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();
|
||||
|
||||
@@ -183,6 +184,7 @@ export class ImportSnapshotRepository {
|
||||
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);
|
||||
@@ -195,6 +197,7 @@ export class ImportSnapshotRepository {
|
||||
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)
|
||||
@@ -252,7 +255,7 @@ export class ImportSnapshotRepository {
|
||||
status: "ok",
|
||||
database: "booking_test",
|
||||
schema: "condon",
|
||||
migrationVersion: "002_legacy_import_and_bookings:snapshot"
|
||||
migrationVersion: "003_delete_usage_record:snapshot"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,6 +306,38 @@ export class ImportSnapshotRepository {
|
||||
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) {
|
||||
|
||||
@@ -17,6 +17,9 @@ const MIGRATION_CHECKSUM =
|
||||
const MIGRATION_V2 = "002_legacy_import_and_bookings";
|
||||
const MIGRATION_V2_CHECKSUM =
|
||||
"cfc38024984c883490cb46ab8ab1fca2e72c67f10dc3a43c179b12a8e6f2405b";
|
||||
const MIGRATION_V3 = "003_delete_usage_record";
|
||||
const MIGRATION_V3_CHECKSUM =
|
||||
"9110464c03ed18385fbc2da1d4369715fcbf83148d7a0f2550c25710a5f54148";
|
||||
|
||||
const expectedColumns = {
|
||||
bookings: [
|
||||
@@ -334,11 +337,12 @@ try {
|
||||
calculate_multiplier: { volatility: "s" },
|
||||
create_usage_record: { volatility: "v" },
|
||||
create_usage_record_v2: { volatility: "v" },
|
||||
delete_usage_record: { volatility: "v" },
|
||||
ensure_booking_for_usage: { volatility: "v" },
|
||||
open_entitlement_period: { volatility: "v" }
|
||||
};
|
||||
const functionsValid =
|
||||
functionResult.rowCount === 5
|
||||
functionResult.rowCount === 6
|
||||
&& functionResult.rows.every(row =>
|
||||
expectedFunctions[row.function_name]?.volatility === row.volatility
|
||||
&& row.security_definer === false
|
||||
@@ -371,13 +375,16 @@ try {
|
||||
&& businessCounts.usage_record_count === 157
|
||||
&& businessCounts.ledger_count === 547,
|
||||
migrationVerified:
|
||||
migrationResult.rowCount === 2
|
||||
migrationResult.rowCount === 3
|
||||
&& 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
|
||||
)
|
||||
&& migrationResult.rows.some(row =>
|
||||
row.version === MIGRATION_V3 && row.checksum === MIGRATION_V3_CHECKSUM
|
||||
)
|
||||
};
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user