Persist project locations

This commit is contained in:
2026-07-26 10:56:29 +02:00
parent 1cf26a932e
commit ac465a1cc0
23 changed files with 1313 additions and 101 deletions
+5
View File
@@ -272,6 +272,11 @@ Distribution-board setup uses `distribution-board.insert` with a complete
stable snapshot of the board, circuit list and four default sections. Its
inverse removes only the same unchanged and still-empty structure; the POST
route requires `expectedRevision` and returns the updated history state.
Floor and room setup use `project-floor.insert` and `project-room.insert` with
complete stable snapshots. Their inverses remove only unchanged records and
reject floors with assigned rooms or rooms referenced by device rows or
retained upgrade data. Both POST routes require `expectedRevision` and return
the updated history state.
Required operations:
+17
View File
@@ -190,6 +190,23 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
structure; Redo restores the same ids
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
### Project Floors and Rooms
- `POST /projects/:projectId/floors`
- body: `{ "name": "EG", "expectedRevision": 13 }`
- executes `project-floor.insert` with a stable floor id
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
- `POST /projects/:projectId/rooms`
- body:
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
- executes `project-room.insert` with a stable room id; `floorId` is optional
and must belong to the project when present
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
- Persistent Undo removes only unchanged, unreferenced records. A floor with
assigned rooms and a room referenced by device rows or retained upgrade data
are rejected instead of silently clearing foreign keys.
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
### Tree Endpoint
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
+10 -1
View File
@@ -70,7 +70,8 @@ Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten
Circuit-, Gerätezeilen-, Projektgeräte- und Projekteinstellungsänderungen
Circuit-, Gerätezeilen-, Projektgeräte-, Projektstruktur- und
Projekteinstellungsänderungen
verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes
inverses Kommando, Revision und Historienstapel gemeinsam committen
beziehungsweise zurückrollen.
@@ -198,6 +199,14 @@ Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
Projektseite übergeben die erwartete Projektrevision; der frühere direkte
Controller-Schreibweg ist entfernt.
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
entfernt, solange ihm kein Raum zugeordnet wurde. Ein Raum wird nur entfernt,
solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf
ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den
aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg
mehr.
## Projektgeräte
@@ -270,11 +270,18 @@ Completed foundation:
list and default-section structure with stable ids; its delete inverse
refuses changed or populated structures and the project-page POST tracks the
returned revision
- `project-floor.insert` and `project-room.insert` persist complete location
records with stable ids; their inverses reject changed or referenced records
so Undo never clears room assignments or device-row links implicitly
- the floor and room POST routes require the current expected revision and the
project page tracks the returned history state
Remaining constraints before completing project version history:
- extend project-scoped commands only when further project mutations are
deliberately added to history; the generic versioned envelope is complete
- all current normal project-scoped runtime writes are behind the revision
boundary; extend it whenever further project mutations are introduced
- project creation remains initialization at revision zero, while global device
templates remain outside any single project history
- do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests
@@ -451,6 +451,9 @@ Implemented foundation:
- distribution-board setup uses `distribution-board.insert` with a complete
stable board/list/default-section snapshot; Undo removes only an unchanged
empty setup and the former direct controller write is removed
- floor and room setup use `project-floor.insert` and `project-room.insert`
with stable ids; their inverses refuse assigned or referenced records and the
former direct repository create methods are removed
- revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction
- a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts",
+25 -10
View File
@@ -204,14 +204,19 @@ export default function ProjectDetailPage() {
async function handleCreateFloor(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !floorName.trim()) {
if (!projectId || !project || !floorName.trim()) {
return;
}
setIsSaving(true);
setError(null);
try {
const created = await createFloor(projectId, { name: floorName.trim() });
setFloors((current) => [...current, created]);
const result = await createFloor(
projectId,
{ name: floorName.trim() },
project.currentRevision
);
setFloors((current) => [...current, result.floor]);
applyProjectRevision(result.history.currentRevision);
setFloorName("");
} catch (err) {
setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden.");
@@ -222,18 +227,28 @@ export default function ProjectDetailPage() {
async function handleCreateRoom(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !roomNumber.trim() || !roomName.trim()) {
if (
!projectId ||
!project ||
!roomNumber.trim() ||
!roomName.trim()
) {
return;
}
setIsSaving(true);
setError(null);
try {
const created = await createRoom(projectId, {
floorId: roomFloorId || undefined,
roomNumber: roomNumber.trim(),
roomName: roomName.trim(),
});
setRooms((current) => [...current, created]);
const result = await createRoom(
projectId,
{
floorId: roomFloorId || undefined,
roomNumber: roomNumber.trim(),
roomName: roomName.trim(),
},
project.currentRevision
);
setRooms((current) => [...current, result.room]);
applyProjectRevision(result.history.currentRevision);
setRoomNumber("");
setRoomName("");
setRoomFloorId("");
+1 -24
View File
@@ -1,5 +1,4 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import { asc, eq } from "drizzle-orm";
import { db } from "../client.js";
import { floors } from "../schema/floors.js";
@@ -11,26 +10,4 @@ export class FloorRepository {
.where(eq(floors.projectId, projectId))
.orderBy(asc(floors.sortOrder), asc(floors.name));
}
async create(projectId: string, name: string) {
const id = crypto.randomUUID();
const existing = await this.listByProject(projectId);
const floor = {
id,
projectId,
name,
sortOrder: existing.length,
};
await db.insert(floors).values(floor);
return floor;
}
async existsInProject(projectId: string, floorId: string) {
const [row] = await db
.select({ id: floors.id })
.from(floors)
.where(and(eq(floors.projectId, projectId), eq(floors.id, floorId)))
.limit(1);
return Boolean(row);
}
}
@@ -0,0 +1,276 @@
import { and, eq } from "drizzle-orm";
import {
assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand,
assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand,
createProjectFloorDeleteProjectCommand,
createProjectFloorInsertProjectCommand,
createProjectRoomDeleteProjectCommand,
createProjectRoomInsertProjectCommand,
projectFloorDeleteCommandType,
projectFloorInsertCommandType,
projectRoomDeleteCommandType,
projectRoomInsertCommandType,
type ProjectFloorSnapshot,
type ProjectLocationStructureProjectCommand,
type ProjectRoomSnapshot,
} from "../../domain/models/project-location-structure-project-command.model.js";
import type {
ExecuteProjectLocationStructureCommandInput,
ProjectLocationStructureProjectCommandStore,
} from "../../domain/ports/project-location-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { consumers } from "../schema/consumers.js";
import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class ProjectLocationStructureProjectCommandRepository
implements ProjectLocationStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectLocationStructureCommandInput) {
return this.database.transaction((tx) => {
const inverse = this.applyCommand(
tx,
input.projectId,
input.command
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: ProjectLocationStructureProjectCommand
): ProjectLocationStructureProjectCommand {
switch (command.type) {
case projectFloorInsertCommandType:
assertProjectFloorInsertProjectCommand(command);
return this.insertFloor(
database,
projectId,
command.payload.floor
);
case projectFloorDeleteCommandType:
assertProjectFloorDeleteProjectCommand(command);
return this.deleteFloor(
database,
projectId,
command.payload.floor
);
case projectRoomInsertCommandType:
assertProjectRoomInsertProjectCommand(command);
return this.insertRoom(
database,
projectId,
command.payload.room
);
case projectRoomDeleteCommandType:
assertProjectRoomDeleteProjectCommand(command);
return this.deleteRoom(
database,
projectId,
command.payload.room
);
}
}
private insertFloor(
database: AppDatabase,
projectId: string,
floor: ProjectFloorSnapshot
) {
this.assertProjectExists(database, projectId);
if (floor.projectId !== projectId) {
throw new Error("Project-floor snapshot does not belong to project.");
}
const existing = database
.select({ id: floors.id })
.from(floors)
.where(eq(floors.id, floor.id))
.get();
if (existing) {
throw new Error("Project-floor id already exists.");
}
database.insert(floors).values(floor).run();
return createProjectFloorDeleteProjectCommand(floor);
}
private deleteFloor(
database: AppDatabase,
projectId: string,
expected: ProjectFloorSnapshot
) {
const persisted = database
.select()
.from(floors)
.where(
and(
eq(floors.id, expected.id),
eq(floors.projectId, projectId)
)
)
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project floor changed before deletion.");
}
const referencedRoom = database
.select({ id: rooms.id })
.from(rooms)
.where(eq(rooms.floorId, expected.id))
.limit(1)
.get();
if (referencedRoom) {
throw new Error(
"A project floor with assigned rooms cannot be removed by history."
);
}
const deleted = database
.delete(floors)
.where(
and(
eq(floors.id, expected.id),
eq(floors.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Project floor could not be deleted.");
}
return createProjectFloorInsertProjectCommand(expected);
}
private insertRoom(
database: AppDatabase,
projectId: string,
room: ProjectRoomSnapshot
) {
this.assertProjectExists(database, projectId);
if (room.projectId !== projectId) {
throw new Error("Project-room snapshot does not belong to project.");
}
if (room.floorId) {
const floor = database
.select({ id: floors.id })
.from(floors)
.where(
and(
eq(floors.id, room.floorId),
eq(floors.projectId, projectId)
)
)
.get();
if (!floor) {
throw new Error("Project room references a foreign floor.");
}
}
const existing = database
.select({ id: rooms.id })
.from(rooms)
.where(eq(rooms.id, room.id))
.get();
if (existing) {
throw new Error("Project-room id already exists.");
}
database.insert(rooms).values(room).run();
return createProjectRoomDeleteProjectCommand(room);
}
private deleteRoom(
database: AppDatabase,
projectId: string,
expected: ProjectRoomSnapshot
) {
const persisted = database
.select()
.from(rooms)
.where(
and(
eq(rooms.id, expected.id),
eq(rooms.projectId, projectId)
)
)
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project room changed before deletion.");
}
const referencedDeviceRow = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.roomId, expected.id))
.limit(1)
.get();
const referencedLegacyConsumer = database
.select({ id: consumers.id })
.from(consumers)
.where(eq(consumers.roomId, expected.id))
.limit(1)
.get();
if (referencedDeviceRow || referencedLegacyConsumer) {
throw new Error(
"A referenced project room cannot be removed by history."
);
}
const deleted = database
.delete(rooms)
.where(
and(
eq(rooms.id, expected.id),
eq(rooms.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Project room could not be deleted.");
}
return createProjectRoomInsertProjectCommand(expected);
}
private assertProjectExists(
database: AppDatabase,
projectId: string
) {
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
}
}
function sameRecord(
expected: object,
actual: object
) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(([key, value]) => actualRecord[key] === value)
);
}
+1 -30
View File
@@ -1,8 +1,6 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import { asc, eq } from "drizzle-orm";
import { db } from "../client.js";
import { rooms } from "../schema/rooms.js";
import type { CreateRoomInput } from "../../shared/validation/project-structure.schemas.js";
export class RoomRepository {
async listByProject(projectId: string) {
@@ -12,31 +10,4 @@ export class RoomRepository {
.where(eq(rooms.projectId, projectId))
.orderBy(asc(rooms.roomNumber), asc(rooms.roomName));
}
async create(projectId: string, input: CreateRoomInput) {
const id = crypto.randomUUID();
const room = {
id,
projectId,
floorId: input.floorId ?? null,
roomNumber: input.roomNumber,
roomName: input.roomName,
};
await db.insert(rooms).values(room);
return room;
}
async existsInProject(projectId: string, roomId: string) {
const [row] = await db
.select({ id: rooms.id })
.from(rooms)
.where(and(eq(rooms.projectId, projectId), eq(rooms.id, roomId)))
.limit(1);
return Boolean(row);
}
async findById(roomId: string) {
const [row] = await db.select().from(rooms).where(eq(rooms.id, roomId)).limit(1);
return row ?? null;
}
}
@@ -0,0 +1,259 @@
import crypto from "node:crypto";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectFloorInsertCommandType = "project-floor.insert" as const;
export const projectFloorDeleteCommandType = "project-floor.delete" as const;
export const projectRoomInsertCommandType = "project-room.insert" as const;
export const projectRoomDeleteCommandType = "project-room.delete" as const;
export const projectLocationStructureCommandSchemaVersion = 1 as const;
export interface ProjectFloorSnapshot {
id: string;
projectId: string;
name: string;
sortOrder: number;
}
export interface ProjectRoomSnapshot {
id: string;
projectId: string;
floorId: string | null;
roomNumber: string;
roomName: string;
}
interface ProjectFloorStructureCommandPayload {
floor: ProjectFloorSnapshot;
}
interface ProjectRoomStructureCommandPayload {
room: ProjectRoomSnapshot;
}
export interface ProjectFloorInsertProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectFloorInsertCommandType;
}
export interface ProjectFloorDeleteProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectFloorDeleteCommandType;
}
export interface ProjectRoomInsertProjectCommand
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectRoomInsertCommandType;
}
export interface ProjectRoomDeleteProjectCommand
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectRoomDeleteCommandType;
}
export type ProjectLocationStructureProjectCommand =
| ProjectFloorInsertProjectCommand
| ProjectFloorDeleteProjectCommand
| ProjectRoomInsertProjectCommand
| ProjectRoomDeleteProjectCommand;
export function createProjectFloorSnapshot(
projectId: string,
name: string,
sortOrder: number
): ProjectFloorSnapshot {
const floor: ProjectFloorSnapshot = {
id: crypto.randomUUID(),
projectId,
name: name.trim(),
sortOrder,
};
assertProjectFloorSnapshot(floor);
return floor;
}
export function createProjectRoomSnapshot(
projectId: string,
input: {
floorId?: string;
roomNumber: string;
roomName: string;
}
): ProjectRoomSnapshot {
const room: ProjectRoomSnapshot = {
id: crypto.randomUUID(),
projectId,
floorId: input.floorId?.trim() || null,
roomNumber: input.roomNumber.trim(),
roomName: input.roomName.trim(),
};
assertProjectRoomSnapshot(room);
return room;
}
export function createProjectFloorInsertProjectCommand(
floor: ProjectFloorSnapshot
): ProjectFloorInsertProjectCommand {
assertProjectFloorSnapshot(floor);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectFloorInsertCommandType,
payload: { floor },
};
}
export function createProjectFloorDeleteProjectCommand(
floor: ProjectFloorSnapshot
): ProjectFloorDeleteProjectCommand {
assertProjectFloorSnapshot(floor);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectFloorDeleteCommandType,
payload: { floor },
};
}
export function createProjectRoomInsertProjectCommand(
room: ProjectRoomSnapshot
): ProjectRoomInsertProjectCommand {
assertProjectRoomSnapshot(room);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectRoomInsertCommandType,
payload: { room },
};
}
export function createProjectRoomDeleteProjectCommand(
room: ProjectRoomSnapshot
): ProjectRoomDeleteProjectCommand {
assertProjectRoomSnapshot(room);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectRoomDeleteCommandType,
payload: { room },
};
}
export function assertProjectFloorInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectFloorInsertProjectCommand {
assertProjectFloorStructureProjectCommand(
command,
projectFloorInsertCommandType
);
}
export function assertProjectFloorDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectFloorDeleteProjectCommand {
assertProjectFloorStructureProjectCommand(
command,
projectFloorDeleteCommandType
);
}
export function assertProjectRoomInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectRoomInsertProjectCommand {
assertProjectRoomStructureProjectCommand(
command,
projectRoomInsertCommandType
);
}
export function assertProjectRoomDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectRoomDeleteProjectCommand {
assertProjectRoomStructureProjectCommand(
command,
projectRoomDeleteCommandType
);
}
export function assertProjectFloorSnapshot(
floor: unknown
): asserts floor is ProjectFloorSnapshot {
if (!isPlainObject(floor) || Object.keys(floor).length !== 4) {
throw new Error("Project-floor snapshot is invalid.");
}
assertNormalizedNonEmptyString(floor.id, "floor.id");
assertNormalizedNonEmptyString(floor.projectId, "floor.projectId");
assertNormalizedNonEmptyString(floor.name, "floor.name");
if (
typeof floor.sortOrder !== "number" ||
!Number.isSafeInteger(floor.sortOrder) ||
floor.sortOrder < 0
) {
throw new Error("floor.sortOrder must be a non-negative integer.");
}
}
export function assertProjectRoomSnapshot(
room: unknown
): asserts room is ProjectRoomSnapshot {
if (!isPlainObject(room) || Object.keys(room).length !== 5) {
throw new Error("Project-room snapshot is invalid.");
}
assertNormalizedNonEmptyString(room.id, "room.id");
assertNormalizedNonEmptyString(room.projectId, "room.projectId");
if (room.floorId !== null) {
assertNormalizedNonEmptyString(room.floorId, "room.floorId");
}
assertNormalizedNonEmptyString(room.roomNumber, "room.roomNumber");
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
}
function assertProjectFloorStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof projectFloorInsertCommandType
| typeof projectFloorDeleteCommandType
) {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported project-floor structure command.");
}
assertProjectFloorSnapshot(command.payload.floor);
}
function assertProjectRoomStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof projectRoomInsertCommandType
| typeof projectRoomDeleteCommandType
) {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported project-room structure command.");
}
assertProjectRoomSnapshot(command.payload.room);
}
function assertNormalizedNonEmptyString(
value: unknown,
field: string
): asserts value is string {
if (
typeof value !== "string" ||
!value ||
value !== value.trim()
) {
throw new Error(`${field} must be a normalized non-empty string.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,26 @@
import type { ProjectLocationStructureProjectCommand } from "../models/project-location-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectLocationStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectLocationStructureProjectCommand;
}
export interface ExecutedProjectLocationStructureCommand {
revision: AppendedProjectRevision;
inverse: ProjectLocationStructureProjectCommand;
}
export interface ProjectLocationStructureProjectCommandStore {
execute(
input: ExecuteProjectLocationStructureCommandInput
): ExecutedProjectLocationStructureCommand;
}
@@ -47,6 +47,16 @@ import {
assertSerializedProjectCommand,
type SerializedProjectCommand,
} from "../models/project-command.model.js";
import {
assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand,
assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand,
projectFloorDeleteCommandType,
projectFloorInsertCommandType,
projectRoomDeleteCommandType,
projectRoomInsertCommandType,
} from "../models/project-location-structure-project-command.model.js";
import {
assertProjectStateRestoreCommand,
projectStateRestoreCommandType,
@@ -87,6 +97,7 @@ import type {
ProjectHistoryDirection,
ProjectHistoryStore,
} from "../ports/project-history.store.js";
import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js";
@@ -112,6 +123,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore,
private readonly projectLocationStructureStore: ProjectLocationStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
@@ -273,6 +285,34 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectFloorDeleteCommandType: {
assertProjectFloorDeleteProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectRoomInsertCommandType: {
assertProjectRoomInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectRoomDeleteCommandType: {
assertProjectRoomDeleteProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitSectionReorderCommandType: {
assertCircuitSectionReorderProjectCommand(input.command);
return this.circuitSectionReorderStore.execute({
+10
View File
@@ -106,6 +106,11 @@ export interface FloorDto {
sortOrder: number;
}
export interface ProjectFloorCommandResultDto
extends ProjectCommandResultDto {
floor: FloorDto;
}
export interface RoomDto {
id: string;
projectId: string;
@@ -146,6 +151,11 @@ export interface ProjectDeviceDto {
voltageV: number | null;
}
export interface ProjectRoomCommandResultDto
extends ProjectCommandResultDto {
room: RoomDto;
}
export interface ProjectDeviceCommandResultDto
extends ProjectCommandResultDto {
projectDevice: ProjectDeviceDto;
+26 -10
View File
@@ -12,11 +12,13 @@ import type {
ProjectCommandResultDto,
ProjectDeviceCommandResultDto,
ProjectDeviceDto,
ProjectFloorCommandResultDto,
ProjectHistoryStateDto,
ProjectRevisionPageDto,
ProjectSnapshotMetadataDto,
ProjectSnapshotRestoreResultDto,
ProjectSettingsCommandResultDto,
ProjectRoomCommandResultDto,
ProjectDeviceSyncCommandResultDto,
ProjectDeviceSyncPreviewDto,
ProjectDto,
@@ -454,22 +456,36 @@ export function listFloors(projectId: string) {
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
}
export function createFloor(projectId: string, input: CreateFloorInput) {
return request<FloorDto>(`/api/projects/${projectId}/floors`, {
method: "POST",
body: JSON.stringify(input),
});
export function createFloor(
projectId: string,
input: CreateFloorInput,
expectedRevision: number
) {
return request<ProjectFloorCommandResultDto>(
`/api/projects/${projectId}/floors`,
{
method: "POST",
body: JSON.stringify({ ...input, expectedRevision }),
}
);
}
export function listRooms(projectId: string) {
return request<RoomDto[]>(`/api/projects/${projectId}/rooms`);
}
export function createRoom(projectId: string, input: CreateRoomInput) {
return request<RoomDto>(`/api/projects/${projectId}/rooms`, {
method: "POST",
body: JSON.stringify(input),
});
export function createRoom(
projectId: string,
input: CreateRoomInput,
expectedRevision: number
) {
return request<ProjectRoomCommandResultDto>(
`/api/projects/${projectId}/rooms`,
{
method: "POST",
body: JSON.stringify({ ...input, expectedRevision }),
}
);
}
export function listGlobalDevices() {
@@ -32,6 +32,10 @@ const commandTypeLabels: Record<string, string> = {
"project.update-settings": "Projekteinstellungen bearbeitet",
"distribution-board.insert": "Verteilung angelegt",
"distribution-board.delete": "Verteilung entfernt",
"project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt",
"project-room.delete": "Raum entfernt",
"project.restore-state": "Projektstand wiederhergestellt",
};
@@ -8,6 +8,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../../db/reposit
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js";
@@ -26,6 +27,8 @@ export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db);
export const projectLocationStructureProjectCommandStore =
new ProjectLocationStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore =
@@ -48,6 +51,7 @@ export const projectCommandService = new ProjectCommandService(
circuitDeviceRowMoveProjectCommandStore,
circuitStructureProjectCommandStore,
distributionBoardStructureProjectCommandStore,
projectLocationStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore,
projectDeviceStructureProjectCommandStore,
+26 -2
View File
@@ -1,6 +1,12 @@
import type { Request, Response } from "express";
import { FloorRepository } from "../../db/repositories/floor.repository.js";
import {
createProjectFloorInsertProjectCommand,
createProjectFloorSnapshot,
} from "../../domain/models/project-location-structure-project-command.model.js";
import { createFloorSchema } from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
const floorRepository = new FloorRepository();
@@ -25,6 +31,24 @@ export async function createFloor(req: Request, res: Response) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const floor = await floorRepository.create(projectId, parsed.data.name);
return res.status(201).json(floor);
const existingFloors = await floorRepository.listByProject(projectId);
const nextSortOrder = existingFloors.length
? Math.max(...existingFloors.map((floor) => floor.sortOrder)) + 1
: 0;
const floor = createProjectFloorSnapshot(
projectId,
parsed.data.name,
nextSortOrder
);
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Geschoss anlegen",
command: createProjectFloorInsertProjectCommand(floor),
});
return res.status(201).json({ ...result, floor });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
+17 -10
View File
@@ -1,9 +1,13 @@
import type { Request, Response } from "express";
import { FloorRepository } from "../../db/repositories/floor.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import {
createProjectRoomInsertProjectCommand,
createProjectRoomSnapshot,
} from "../../domain/models/project-location-structure-project-command.model.js";
import { createRoomSchema } from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
const floorRepository = new FloorRepository();
const roomRepository = new RoomRepository();
export async function listRoomsByProject(req: Request, res: Response) {
@@ -27,13 +31,16 @@ export async function createRoom(req: Request, res: Response) {
return res.status(400).json({ error: parsed.error.flatten() });
}
if (parsed.data.floorId) {
const hasValidFloor = await floorRepository.existsInProject(projectId, parsed.data.floorId);
if (!hasValidFloor) {
return res.status(400).json({ error: "Floor does not belong to the provided project." });
}
const room = createProjectRoomSnapshot(projectId, parsed.data);
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Raum anlegen",
command: createProjectRoomInsertProjectCommand(room),
});
return res.status(201).json({ ...result, room });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
const room = await roomRepository.create(projectId, parsed.data);
return res.status(201).json(room);
}
@@ -22,15 +22,21 @@ export const createDistributionBoardSchema = z
})
.strict();
export const createFloorSchema = z.object({
name: z.string().min(1),
});
export const createFloorSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1),
})
.strict();
export const createRoomSchema = z.object({
floorId: z.string().min(1).optional(),
roomNumber: z.string().min(1),
roomName: z.string().min(1),
});
export const createRoomSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).optional(),
roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1),
})
.strict();
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectSettingsInput = z.infer<
@@ -39,5 +45,3 @@ export type UpdateProjectSettingsInput = z.infer<
export type CreateDistributionBoardInput = z.infer<
typeof createDistributionBoardSchema
>;
export type CreateFloorInput = z.infer<typeof createFloorSchema>;
export type CreateRoomInput = z.infer<typeof createRoomSchema>;
+72
View File
@@ -17,6 +17,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
@@ -29,6 +30,8 @@ import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { floors } from "../src/db/schema/floors.js";
import { rooms } from "../src/db/schema/rooms.js";
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
@@ -46,6 +49,12 @@ import {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
import {
createProjectFloorInsertProjectCommand,
createProjectFloorSnapshot,
createProjectRoomInsertProjectCommand,
createProjectRoomSnapshot,
} from "../src/domain/models/project-location-structure-project-command.model.js";
import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js";
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
@@ -105,6 +114,7 @@ function createService(context: DatabaseContext) {
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db),
new DistributionBoardStructureProjectCommandRepository(context.db),
new ProjectLocationStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(context.db),
@@ -1036,6 +1046,68 @@ describe("project command service", () => {
}
});
it("dispatches floor and room setup with their persisted inverses", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const floor = createProjectFloorSnapshot(
"project-1",
"EG",
0
);
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createProjectFloorInsertProjectCommand(floor),
});
const room = createProjectRoomSnapshot("project-1", {
floorId: floor.id,
roomNumber: "001",
roomName: "Technik",
});
const created = service.executeUser({
projectId: "project-1",
expectedRevision: 1,
command: createProjectRoomInsertProjectCommand(room),
});
assert.equal(created.history.currentRevision, 2);
assert.ok(
context.db
.select()
.from(rooms)
.where(eq(rooms.id, room.id))
.get()
);
createService(context).undo({
projectId: "project-1",
expectedRevision: 2,
});
assert.equal(
context.db
.select()
.from(rooms)
.where(eq(rooms.id, room.id))
.get(),
undefined
);
createService(context).undo({
projectId: "project-1",
expectedRevision: 3,
});
assert.equal(
context.db
.select()
.from(floors)
.where(eq(floors.id, floor.id))
.get(),
undefined
);
} finally {
context.close();
}
});
it("rejects unsupported and malformed commands before domain writes", () => {
const context = createTestDatabase();
try {
@@ -0,0 +1,464 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
import { consumers } from "../src/db/schema/consumers.js";
import { floors } from "../src/db/schema/floors.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import {
createProjectFloorInsertProjectCommand,
createProjectFloorSnapshot,
createProjectRoomInsertProjectCommand,
createProjectRoomSnapshot,
} from "../src/domain/models/project-location-structure-project-command.model.js";
import { createFloor, createRoom } from "../src/frontend/utils/api.js";
import {
createFloorSchema,
createRoomSchema,
} from "../src/shared/validation/project-structure.schemas.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Foreign project" },
])
.run();
return context;
}
function getCounts(context: DatabaseContext) {
return {
floors: context.db.select().from(floors).all().length,
rooms: context.db.select().from(rooms).all().length,
revisions: context.db.select().from(projectRevisions).all().length,
};
}
describe("project-location structure project command", () => {
it("normalizes snapshots and sends optimistic frontend revisions", async () => {
const floor = createProjectFloorSnapshot("project-1", " EG ", 0);
const room = createProjectRoomSnapshot("project-1", {
floorId: floor.id,
roomNumber: " 001 ",
roomName: " Technik ",
});
assert.equal(floor.name, "EG");
assert.equal(room.roomNumber, "001");
assert.equal(room.roomName, "Technik");
assert.throws(
() =>
createProjectFloorInsertProjectCommand({
...floor,
name: " EG ",
}),
/normalized/
);
assert.equal(
createFloorSchema.safeParse({ name: "EG" }).success,
false
);
assert.equal(
createRoomSchema.safeParse({
expectedRevision: 0,
roomNumber: "001",
roomName: "Technik",
unknown: true,
}).success,
false
);
const requests: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
body: JSON.parse(String(init?.body)),
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await createFloor("project-1", { name: "EG" }, 7);
await createRoom(
"project-1",
{
floorId: "floor-1",
roomNumber: "001",
roomName: "Technik",
},
8
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1/floors",
body: { name: "EG", expectedRevision: 7 },
},
{
url: "/api/projects/project-1/rooms",
body: {
floorId: "floor-1",
roomNumber: "001",
roomName: "Technik",
expectedRevision: 8,
},
},
]);
});
it("creates a stable floor and supports persisted undo and redo", () => {
const context = createTestDatabase();
try {
const repository =
new ProjectLocationStructureProjectCommandRepository(context.db);
const history = new ProjectHistoryRepository(context.db);
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
const command = createProjectFloorInsertProjectCommand(floor);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(getCounts(context), {
floors: 1,
rooms: 0,
revisions: 1,
});
const undoStep = history.getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: inserted.inverse,
});
assert.deepEqual(getCounts(context), {
floors: 0,
rooms: 0,
revisions: 2,
});
const redoStep = history.getNextCommand("project-1", "redo");
assert.ok(redoStep);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: redoStep.changeSetId,
command,
});
assert.deepEqual(
context.db
.select()
.from(floors)
.where(eq(floors.id, floor.id))
.get(),
floor
);
} finally {
context.close();
}
});
it("creates a stable room and supports persisted undo and redo", () => {
const context = createTestDatabase();
try {
context.db
.insert(floors)
.values({
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 0,
})
.run();
const repository =
new ProjectLocationStructureProjectCommandRepository(context.db);
const history = new ProjectHistoryRepository(context.db);
const room = createProjectRoomSnapshot("project-1", {
floorId: "floor-1",
roomNumber: "001",
roomName: "Technik",
});
const command = createProjectRoomInsertProjectCommand(room);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
const undoStep = history.getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: inserted.inverse,
});
assert.equal(
context.db
.select()
.from(rooms)
.where(eq(rooms.id, room.id))
.get(),
undefined
);
const redoStep = history.getNextCommand("project-1", "redo");
assert.ok(redoStep);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: redoStep.changeSetId,
command,
});
assert.deepEqual(
context.db
.select()
.from(rooms)
.where(eq(rooms.id, room.id))
.get(),
room
);
} finally {
context.close();
}
});
it("rejects foreign, stale and changed locations without partial writes", () => {
const context = createTestDatabase();
try {
context.db
.insert(floors)
.values({
id: "foreign-floor",
projectId: "project-2",
name: "Fremd",
sortOrder: 0,
})
.run();
const repository =
new ProjectLocationStructureProjectCommandRepository(context.db);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectRoomInsertProjectCommand(
createProjectRoomSnapshot("project-1", {
floorId: "foreign-floor",
roomNumber: "001",
roomName: "Fremdraum",
})
),
}),
/foreign floor/
);
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
const command = createProjectFloorInsertProjectCommand(floor);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command,
}),
ProjectRevisionConflictError
);
assert.deepEqual(getCounts(context), {
floors: 1,
rooms: 0,
revisions: 0,
});
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
context.db
.update(floors)
.set({ name: "Direkt geändert" })
.where(eq(floors.id, floor.id))
.run();
const undoStep = new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: inserted.inverse,
}),
/changed before deletion/
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
1
);
} finally {
context.close();
}
});
it("refuses referenced deletes and rolls back late history failures", () => {
const floorContext = createTestDatabase();
try {
const repository =
new ProjectLocationStructureProjectCommandRepository(
floorContext.db
);
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectFloorInsertProjectCommand(floor),
});
floorContext.db
.insert(rooms)
.values({
id: "room-1",
projectId: "project-1",
floorId: floor.id,
roomNumber: "001",
roomName: "Technik",
})
.run();
const undoStep = new ProjectHistoryRepository(
floorContext.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: inserted.inverse,
}),
/assigned rooms/
);
} finally {
floorContext.close();
}
const roomContext = createTestDatabase();
try {
const repository =
new ProjectLocationStructureProjectCommandRepository(
roomContext.db
);
const room = createProjectRoomSnapshot("project-1", {
roomNumber: "001",
roomName: "Technik",
});
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectRoomInsertProjectCommand(room),
});
roomContext.db
.insert(consumers)
.values({
id: "legacy-consumer-1",
projectId: "project-1",
roomId: room.id,
name: "Bestand",
quantity: 1,
installedPowerPerUnitKw: 1,
demandFactor: 1,
})
.run();
const undoStep = new ProjectHistoryRepository(
roomContext.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: inserted.inverse,
}),
/referenced project room/
);
} finally {
roomContext.close();
}
const rollbackContext = createTestDatabase();
try {
rollbackContext.sqlite.exec(`
CREATE TRIGGER fail_project_location_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced project-location history failure');
END;
`);
const repository =
new ProjectLocationStructureProjectCommandRepository(
rollbackContext.db
);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectFloorInsertProjectCommand(
createProjectFloorSnapshot(
"project-1",
"Rollback",
0
)
),
}),
/forced project-location history failure/
);
assert.deepEqual(getCounts(rollbackContext), {
floors: 0,
rooms: 0,
revisions: 0,
});
} finally {
rollbackContext.close();
}
});
});
@@ -20,6 +20,7 @@ import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/pr
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
@@ -134,6 +135,7 @@ function createService(context: DatabaseContext) {
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db),
new DistributionBoardStructureProjectCommandRepository(context.db),
new ProjectLocationStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(context.db),