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 stable snapshot of the board, circuit list and four default sections. Its
inverse removes only the same unchanged and still-empty structure; the POST inverse removes only the same unchanged and still-empty structure; the POST
route requires `expectedRevision` and returns the updated history state. 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: Required operations:
+17
View File
@@ -190,6 +190,23 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
structure; Redo restores the same ids structure; Redo restores the same ids
- stale revisions return `409 PROJECT_REVISION_CONFLICT` - 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 ### Tree Endpoint
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree` - `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 und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten 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 verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes
inverses Kommando, Revision und Historienstapel gemeinsam committen inverses Kommando, Revision und Historienstapel gemeinsam committen
beziehungsweise zurückrollen. 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 vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
Projektseite übergeben die erwartete Projektrevision; der frühere direkte Projektseite übergeben die erwartete Projektrevision; der frühere direkte
Controller-Schreibweg ist entfernt. 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 ## Projektgeräte
@@ -270,11 +270,18 @@ Completed foundation:
list and default-section structure with stable ids; its delete inverse list and default-section structure with stable ids; its delete inverse
refuses changed or populated structures and the project-page POST tracks the refuses changed or populated structures and the project-page POST tracks the
returned revision 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: Remaining constraints before completing project version history:
- extend project-scoped commands only when further project mutations are - all current normal project-scoped runtime writes are behind the revision
deliberately added to history; the generic versioned envelope is complete 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 - do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests - 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 - distribution-board setup uses `distribution-board.insert` with a complete
stable board/list/default-section snapshot; Undo removes only an unchanged stable board/list/default-section snapshot; Undo removes only an unchanged
empty setup and the former direct controller write is removed 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 - revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction in one SQLite transaction
- a stale expected revision produces no history writes - a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "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": "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-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:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
+22 -7
View File
@@ -204,14 +204,19 @@ export default function ProjectDetailPage() {
async function handleCreateFloor(event: FormEvent<HTMLFormElement>) { async function handleCreateFloor(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!projectId || !floorName.trim()) { if (!projectId || !project || !floorName.trim()) {
return; return;
} }
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const created = await createFloor(projectId, { name: floorName.trim() }); const result = await createFloor(
setFloors((current) => [...current, created]); projectId,
{ name: floorName.trim() },
project.currentRevision
);
setFloors((current) => [...current, result.floor]);
applyProjectRevision(result.history.currentRevision);
setFloorName(""); setFloorName("");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden."); 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>) { async function handleCreateRoom(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!projectId || !roomNumber.trim() || !roomName.trim()) { if (
!projectId ||
!project ||
!roomNumber.trim() ||
!roomName.trim()
) {
return; return;
} }
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const created = await createRoom(projectId, { const result = await createRoom(
projectId,
{
floorId: roomFloorId || undefined, floorId: roomFloorId || undefined,
roomNumber: roomNumber.trim(), roomNumber: roomNumber.trim(),
roomName: roomName.trim(), roomName: roomName.trim(),
}); },
setRooms((current) => [...current, created]); project.currentRevision
);
setRooms((current) => [...current, result.room]);
applyProjectRevision(result.history.currentRevision);
setRoomNumber(""); setRoomNumber("");
setRoomName(""); setRoomName("");
setRoomFloorId(""); setRoomFloorId("");
+1 -24
View File
@@ -1,5 +1,4 @@
import crypto from "node:crypto"; import { asc, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { db } from "../client.js"; import { db } from "../client.js";
import { floors } from "../schema/floors.js"; import { floors } from "../schema/floors.js";
@@ -11,26 +10,4 @@ export class FloorRepository {
.where(eq(floors.projectId, projectId)) .where(eq(floors.projectId, projectId))
.orderBy(asc(floors.sortOrder), asc(floors.name)); .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 { asc, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { db } from "../client.js"; import { db } from "../client.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import type { CreateRoomInput } from "../../shared/validation/project-structure.schemas.js";
export class RoomRepository { export class RoomRepository {
async listByProject(projectId: string) { async listByProject(projectId: string) {
@@ -12,31 +10,4 @@ export class RoomRepository {
.where(eq(rooms.projectId, projectId)) .where(eq(rooms.projectId, projectId))
.orderBy(asc(rooms.roomNumber), asc(rooms.roomName)); .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, assertSerializedProjectCommand,
type SerializedProjectCommand, type SerializedProjectCommand,
} from "../models/project-command.model.js"; } 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 { import {
assertProjectStateRestoreCommand, assertProjectStateRestoreCommand,
projectStateRestoreCommandType, projectStateRestoreCommandType,
@@ -87,6 +97,7 @@ import type {
ProjectHistoryDirection, ProjectHistoryDirection,
ProjectHistoryStore, ProjectHistoryStore,
} from "../ports/project-history.store.js"; } 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 { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-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"; 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 deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore, private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore,
private readonly projectLocationStructureStore: ProjectLocationStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore, private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore, private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
@@ -273,6 +285,34 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).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: { case circuitSectionReorderCommandType: {
assertCircuitSectionReorderProjectCommand(input.command); assertCircuitSectionReorderProjectCommand(input.command);
return this.circuitSectionReorderStore.execute({ return this.circuitSectionReorderStore.execute({
+10
View File
@@ -106,6 +106,11 @@ export interface FloorDto {
sortOrder: number; sortOrder: number;
} }
export interface ProjectFloorCommandResultDto
extends ProjectCommandResultDto {
floor: FloorDto;
}
export interface RoomDto { export interface RoomDto {
id: string; id: string;
projectId: string; projectId: string;
@@ -146,6 +151,11 @@ export interface ProjectDeviceDto {
voltageV: number | null; voltageV: number | null;
} }
export interface ProjectRoomCommandResultDto
extends ProjectCommandResultDto {
room: RoomDto;
}
export interface ProjectDeviceCommandResultDto export interface ProjectDeviceCommandResultDto
extends ProjectCommandResultDto { extends ProjectCommandResultDto {
projectDevice: ProjectDeviceDto; projectDevice: ProjectDeviceDto;
+24 -8
View File
@@ -12,11 +12,13 @@ import type {
ProjectCommandResultDto, ProjectCommandResultDto,
ProjectDeviceCommandResultDto, ProjectDeviceCommandResultDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectFloorCommandResultDto,
ProjectHistoryStateDto, ProjectHistoryStateDto,
ProjectRevisionPageDto, ProjectRevisionPageDto,
ProjectSnapshotMetadataDto, ProjectSnapshotMetadataDto,
ProjectSnapshotRestoreResultDto, ProjectSnapshotRestoreResultDto,
ProjectSettingsCommandResultDto, ProjectSettingsCommandResultDto,
ProjectRoomCommandResultDto,
ProjectDeviceSyncCommandResultDto, ProjectDeviceSyncCommandResultDto,
ProjectDeviceSyncPreviewDto, ProjectDeviceSyncPreviewDto,
ProjectDto, ProjectDto,
@@ -454,22 +456,36 @@ export function listFloors(projectId: string) {
return request<FloorDto[]>(`/api/projects/${projectId}/floors`); return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
} }
export function createFloor(projectId: string, input: CreateFloorInput) { export function createFloor(
return request<FloorDto>(`/api/projects/${projectId}/floors`, { projectId: string,
input: CreateFloorInput,
expectedRevision: number
) {
return request<ProjectFloorCommandResultDto>(
`/api/projects/${projectId}/floors`,
{
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify({ ...input, expectedRevision }),
}); }
);
} }
export function listRooms(projectId: string) { export function listRooms(projectId: string) {
return request<RoomDto[]>(`/api/projects/${projectId}/rooms`); return request<RoomDto[]>(`/api/projects/${projectId}/rooms`);
} }
export function createRoom(projectId: string, input: CreateRoomInput) { export function createRoom(
return request<RoomDto>(`/api/projects/${projectId}/rooms`, { projectId: string,
input: CreateRoomInput,
expectedRevision: number
) {
return request<ProjectRoomCommandResultDto>(
`/api/projects/${projectId}/rooms`,
{
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify({ ...input, expectedRevision }),
}); }
);
} }
export function listGlobalDevices() { export function listGlobalDevices() {
@@ -32,6 +32,10 @@ const commandTypeLabels: Record<string, string> = {
"project.update-settings": "Projekteinstellungen bearbeitet", "project.update-settings": "Projekteinstellungen bearbeitet",
"distribution-board.insert": "Verteilung angelegt", "distribution-board.insert": "Verteilung angelegt",
"distribution-board.delete": "Verteilung entfernt", "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", "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 { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-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 { 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 { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-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"; import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js";
@@ -26,6 +27,8 @@ export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db); new CircuitStructureProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore = export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db); new DistributionBoardStructureProjectCommandRepository(db);
export const projectLocationStructureProjectCommandStore =
new ProjectLocationStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore = export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db); new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore = export const circuitSectionRenumberProjectCommandStore =
@@ -48,6 +51,7 @@ export const projectCommandService = new ProjectCommandService(
circuitDeviceRowMoveProjectCommandStore, circuitDeviceRowMoveProjectCommandStore,
circuitStructureProjectCommandStore, circuitStructureProjectCommandStore,
distributionBoardStructureProjectCommandStore, distributionBoardStructureProjectCommandStore,
projectLocationStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore, circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore, circuitSectionRenumberProjectCommandStore,
projectDeviceStructureProjectCommandStore, projectDeviceStructureProjectCommandStore,
+26 -2
View File
@@ -1,6 +1,12 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { FloorRepository } from "../../db/repositories/floor.repository.js"; 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 { 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(); 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() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
const floor = await floorRepository.create(projectId, parsed.data.name); const existingFloors = await floorRepository.listByProject(projectId);
return res.status(201).json(floor); 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 type { Request, Response } from "express";
import { FloorRepository } from "../../db/repositories/floor.repository.js";
import { RoomRepository } from "../../db/repositories/room.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 { 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(); const roomRepository = new RoomRepository();
export async function listRoomsByProject(req: Request, res: Response) { 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() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
if (parsed.data.floorId) { const room = createProjectRoomSnapshot(projectId, parsed.data);
const hasValidFloor = await floorRepository.existsInProject(projectId, parsed.data.floorId); try {
if (!hasValidFloor) { const result = projectCommandService.executeUser({
return res.status(400).json({ error: "Floor does not belong to the provided project." }); 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(); .strict();
export const createFloorSchema = z.object({ export const createFloorSchema = z
name: z.string().min(1), .object({
}); expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1),
})
.strict();
export const createRoomSchema = z.object({ export const createRoomSchema = z
floorId: z.string().min(1).optional(), .object({
roomNumber: z.string().min(1), expectedRevision: expectedProjectRevisionSchema,
roomName: z.string().min(1), 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 CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectSettingsInput = z.infer< export type UpdateProjectSettingsInput = z.infer<
@@ -39,5 +45,3 @@ export type UpdateProjectSettingsInput = z.infer<
export type CreateDistributionBoardInput = z.infer< export type CreateDistributionBoardInput = z.infer<
typeof createDistributionBoardSchema 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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.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 { 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 { 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 { 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 { 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 { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.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 { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.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"; import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
@@ -46,6 +49,12 @@ import {
createDistributionBoardInsertProjectCommand, createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot, createDistributionBoardStructureSnapshot,
} from "../src/domain/models/distribution-board-structure-project-command.model.js"; } 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 { 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 { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-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 CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db),
new DistributionBoardStructureProjectCommandRepository(context.db), new DistributionBoardStructureProjectCommandRepository(context.db),
new ProjectLocationStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(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", () => { it("rejects unsupported and malformed commands before domain writes", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { 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 { 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 { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.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"; 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 CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db),
new DistributionBoardStructureProjectCommandRepository(context.db), new DistributionBoardStructureProjectCommandRepository(context.db),
new ProjectLocationStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(context.db), new ProjectDeviceStructureProjectCommandRepository(context.db),