Add project transfer controls

This commit is contained in:
2026-07-28 19:36:26 +02:00
parent da8115fe1d
commit feec814dc5
5 changed files with 353 additions and 8 deletions
@@ -6,7 +6,6 @@ import {
} from "../../domain/models/project-state-restore-command.model.js";
import {
parseProjectStateSnapshot,
serializeProjectStateSnapshot,
type ProjectStateSnapshot,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
@@ -24,11 +23,11 @@ import { floors } from "../schema/floors.js";
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
import { projectDevices } from "../schema/project-devices.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
} from "./project-state-snapshot.persistence.js";
@@ -63,7 +62,8 @@ export class ProjectStateRestoreCommandRepository
}
if (
current.payloadSha256 !==
input.command.payload.expectedStateSha256
input.command.payload.expectedStateSha256 &&
!matchesHistoryRestoreTarget(tx, input, current.state)
) {
throw new ProjectStateConflictError(
input.projectId,
@@ -75,18 +75,90 @@ export class ProjectStateRestoreCommandRepository
const targetState = parseProjectStateSnapshot(
input.command.payload.targetState
);
const targetPayloadJson = serializeProjectStateSnapshot(targetState);
replaceProjectState(tx, targetState);
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
if (!persistedTarget) {
throw new Error("Restored project could not be loaded.");
}
const inverse = createProjectStateRestoreCommand(
hashProjectStatePayload(targetPayloadJson),
persistedTarget.payloadSha256,
current.state
);
replaceProjectState(tx, targetState);
return inverse;
}
}
function matchesHistoryRestoreTarget(
database: AppDatabase,
input: ExecuteProjectStateRestoreCommandInput,
currentState: ProjectStateSnapshot
) {
if (
(input.source !== "undo" && input.source !== "redo") ||
!input.historyTargetChangeSetId
) {
return false;
}
const changeSet = database
.select({
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
inversePayloadJson: projectChangeSets.inversePayloadJson,
})
.from(projectChangeSets)
.where(eq(projectChangeSets.id, input.historyTargetChangeSetId))
.get();
if (!changeSet) {
return false;
}
try {
const serialized =
input.source === "undo"
? changeSet.forwardPayloadJson
: changeSet.inversePayloadJson;
const command = JSON.parse(serialized) as {
type?: unknown;
payload?: { targetState?: unknown };
};
if (command.type !== "project.restore-state") {
return false;
}
const expectedState = parseProjectStateSnapshot(
command.payload?.targetState
);
return (
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState)
);
} catch {
return false;
}
}
function canonicalProjectState(state: ProjectStateSnapshot) {
return JSON.stringify({
...state,
distributionBoards: byId(state.distributionBoards),
circuitLists: byId(state.circuitLists),
circuitSections: byId(state.circuitSections),
circuits: byId(
state.circuits.map((circuit) => ({
...circuit,
deviceRows: byId(circuit.deviceRows),
}))
),
projectDevices: byId(state.projectDevices),
floors: byId(state.floors),
rooms: byId(state.rooms),
});
}
function byId<T extends { id: string }>(entries: readonly T[]) {
return [...entries].sort((left, right) =>
left.id.localeCompare(right.id)
);
}
function replaceProjectState(
database: AppDatabase,
state: ProjectStateSnapshot