Compare commits
12 Commits
2eba4ea75e
...
1cf26a932e
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cf26a932e | |||
| 1a77eedaa8 | |||
| 59d442593f | |||
| b2763f72d5 | |||
| d00ae30bda | |||
| 384a5769ab | |||
| 7c670fece3 | |||
| 5a0c9019af | |||
| 4b47bc2bcc | |||
| c45afd0981 | |||
| abcb468807 | |||
| 08a2775a88 |
@@ -220,15 +220,34 @@ After saving, the row becomes linked to the new project device.
|
|||||||
|
|
||||||
## Undo / Redo
|
## Undo / Redo
|
||||||
|
|
||||||
The editor's visible undo/redo stack remains session-local while move, reorder
|
The editor reads undo/redo eligibility from the project-wide server history on
|
||||||
and renumber operations are being migrated. Existing Circuit and
|
initial load and after every tree reload. Undo/redo therefore remains available
|
||||||
CircuitDeviceRow cell edits plus standalone insertion/deletion already execute
|
after a page refresh or application restart. All currently supported Circuit
|
||||||
persistent project commands; their toolbar undo/redo actions use the
|
and CircuitDeviceRow writes execute persistent project commands. Applying a
|
||||||
project-wide history endpoints. Insertions use client-generated stable UUIDs,
|
sorted view across multiple sections is one atomic `circuit.reorder-sections`
|
||||||
and deletion undo restores the same ids from complete server snapshots. The
|
command and one undo step. Explicit renumbering uses the collision-safe
|
||||||
tree response supplies the optimistic `currentRevision`; obsolete direct field
|
`circuit.renumber-section` command and is never triggered implicitly.
|
||||||
PATCH, structure POST and CircuitDeviceRow DELETE routes are removed.
|
Immutable revision metadata is available through the paginated
|
||||||
Reloading the page does not yet reconstruct the visible editor stack.
|
`GET /api/projects/:projectId/history/revisions` endpoint; it does not expose
|
||||||
|
stored command payloads.
|
||||||
|
Named logical snapshots can be created and listed through project-scoped API
|
||||||
|
endpoints. Their schema-versioned payload contains the complete supported
|
||||||
|
project runtime state and a SHA-256, excludes global/upgrade-only data and does
|
||||||
|
not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
|
project data atomically and records a new `restore` revision with a complete
|
||||||
|
inverse command. Restore can therefore be undone and redone after a restart.
|
||||||
|
The central revision boundary creates an automatic logical snapshot after each
|
||||||
|
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||||
|
project. Named snapshots are never removed by this retention policy.
|
||||||
|
The project page exposes persistent project-wide Undo/Redo in the header of an
|
||||||
|
initially collapsed German snapshot/timeline UI, with explicit restore
|
||||||
|
confirmation and cursor-based loading of older revision metadata.
|
||||||
|
Insertions and generated move targets use client-generated stable UUIDs, and
|
||||||
|
undo restores the same ids from complete server snapshots. The tree response
|
||||||
|
supplies the optimistic `currentRevision`; obsolete direct field PATCH,
|
||||||
|
structure POST, move, reorder, renumber, identifier-restore, Circuit and
|
||||||
|
CircuitDeviceRow DELETE routes are removed.
|
||||||
CircuitDeviceRow moves between existing circuits and moves that create one new
|
CircuitDeviceRow moves between existing circuits and moves that create one new
|
||||||
placeholder target circuit are persisted. The latter stores the complete empty
|
placeholder target circuit are persisted. The latter stores the complete empty
|
||||||
target snapshot so undo can restore the rows and remove only the unchanged
|
target snapshot so undo can restore the rows and remove only the unchanged
|
||||||
@@ -246,6 +265,13 @@ that have remained unchanged. ProjectDevice create, update, delete and
|
|||||||
global-to-project copy API/UI paths use these persistent commands and track the
|
global-to-project copy API/UI paths use these persistent commands and track the
|
||||||
returned project revision. ProjectDevice synchronization/disconnect API and UI
|
returned project revision. ProjectDevice synchronization/disconnect API and UI
|
||||||
paths do the same; their undo action uses the project-wide history endpoint.
|
paths do the same; their undo action uses the project-wide history endpoint.
|
||||||
|
Project voltage settings use the persistent `project.update-settings` command.
|
||||||
|
Both voltage defaults change in one revision and Undo/Redo restores them
|
||||||
|
together; the project PUT route requires `expectedRevision`.
|
||||||
|
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.
|
||||||
|
|
||||||
Required operations:
|
Required operations:
|
||||||
|
|
||||||
@@ -287,7 +313,7 @@ Users must be able to override sizing suggestions.
|
|||||||
- Never edit an already applied migration.
|
- Never edit an already applied migration.
|
||||||
- Back up an existing database before applying a new migration.
|
- Back up an existing database before applying a new migration.
|
||||||
- Inspect generated SQL; it must contain only the intended schema change.
|
- Inspect generated SQL; it must contain only the intended schema change.
|
||||||
- Keep database backups separate from future logical project snapshots.
|
- Keep database backups separate from logical project snapshots.
|
||||||
- Do not import the global SQLite singleton into domain services.
|
- Do not import the global SQLite singleton into domain services.
|
||||||
- Keep synchronous SQLite transaction behavior inside persistence adapters.
|
- Keep synchronous SQLite transaction behavior inside persistence adapters.
|
||||||
- Preserve stable UUIDs and explicit transaction boundaries for a later
|
- Preserve stable UUIDs and explicit transaction boundaries for a later
|
||||||
@@ -295,8 +321,6 @@ Users must be able to override sizing suggestions.
|
|||||||
|
|
||||||
## Current Deferred Work
|
## Current Deferred Work
|
||||||
|
|
||||||
- complete persistent project command coverage and the undo/redo UI cutover
|
|
||||||
- named logical snapshots and restore
|
|
||||||
- Revit/CSV/IFCGUID round-trip
|
- Revit/CSV/IFCGUID round-trip
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
- multi-user/PostgreSQL operation
|
- multi-user/PostgreSQL operation
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
|||||||
|
|
||||||
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
||||||
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
||||||
persistentes Undo/Redo, Projektversionen und der Revit-/IFCGUID-Datenaustausch sind
|
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
||||||
noch nicht implementiert.
|
implementiert.
|
||||||
|
|
||||||
## Unterstützter Arbeitsablauf
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
@@ -18,7 +18,9 @@ noch nicht implementiert.
|
|||||||
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
||||||
- komplette Stromkreisblöcke filtern und sortieren
|
- komplette Stromkreisblöcke filtern und sortieren
|
||||||
- BMKs stabil halten und nur auf ausdrücklichen Befehl neu nummerieren
|
- BMKs stabil halten und nur auf ausdrücklichen Befehl neu nummerieren
|
||||||
- Änderungen innerhalb der aktuellen Editorsitzung rückgängig machen und wiederholen
|
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
||||||
|
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
||||||
|
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ this value for optimistic concurrency checks.
|
|||||||
- `GET /projects/:projectId/history`
|
- `GET /projects/:projectId/history`
|
||||||
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
|
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
|
||||||
change-set id for both persistent stacks
|
change-set id for both persistent stacks
|
||||||
|
- `GET /projects/:projectId/history/revisions`
|
||||||
|
- returns revision metadata in descending `revisionNumber` order
|
||||||
|
- optional query: `limit` (default `25`, maximum `100`) and exclusive
|
||||||
|
`beforeRevision`
|
||||||
|
- returns `projectId`, `currentRevision`, `revisions` and
|
||||||
|
`nextBeforeRevision`; use the latter as the next page's
|
||||||
|
`beforeRevision`
|
||||||
|
- each revision contains ids, number, timestamp, actor, source, description,
|
||||||
|
command type and payload schema version
|
||||||
|
- stored forward/inverse command payloads are intentionally not exposed
|
||||||
- `POST /projects/:projectId/commands`
|
- `POST /projects/:projectId/commands`
|
||||||
- executes a supported versioned command as a new user revision
|
- executes a supported versioned command as a new user revision
|
||||||
- body: `{ "expectedRevision": 0, "command": { ... } }`
|
- body: `{ "expectedRevision": 0, "command": { ... } }`
|
||||||
@@ -22,23 +32,50 @@ this value for optimistic concurrency checks.
|
|||||||
- body: `{ "expectedRevision": 1 }`
|
- body: `{ "expectedRevision": 1 }`
|
||||||
- executes the eligible inverse or forward command as a new auditable
|
- executes the eligible inverse or forward command as a new auditable
|
||||||
revision
|
revision
|
||||||
|
- `GET /projects/:projectId/snapshots`
|
||||||
|
- lists named and automatic logical snapshot metadata, including `kind`,
|
||||||
|
without returning payload JSON
|
||||||
|
- `POST /projects/:projectId/snapshots`
|
||||||
|
- body:
|
||||||
|
`{ "expectedRevision": 12, "name": "Vor Ausschreibung", "description": "Optional" }`
|
||||||
|
- atomically captures the complete project-scoped runtime state at the
|
||||||
|
expected revision
|
||||||
|
- returns `201` with source revision, schema version, SHA-256 and creation
|
||||||
|
metadata
|
||||||
|
- snapshot creation does not increment the project revision or alter
|
||||||
|
undo/redo stacks
|
||||||
|
- duplicate names and stale revisions return `409`
|
||||||
|
- the central revision boundary additionally creates `automatic` snapshots
|
||||||
|
after 25 further revisions and retains the newest 12 per project; named
|
||||||
|
snapshots are never removed by this policy
|
||||||
|
- `POST /projects/:projectId/snapshots/:snapshotId/restore`
|
||||||
|
- body: `{ "expectedRevision": 13 }`
|
||||||
|
- verifies the stored payload checksum and atomically restores the complete
|
||||||
|
supported project state as revision `14` with source `restore`
|
||||||
|
- stores the complete pre-restore state as the inverse command, so project
|
||||||
|
Undo/Redo can revert or repeat the restore after a restart
|
||||||
|
- stale revisions or state checks return `409`; unknown snapshots return
|
||||||
|
`404`
|
||||||
|
- clients cannot submit arbitrary `project.restore-state` payloads through
|
||||||
|
the generic command endpoint
|
||||||
|
|
||||||
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
||||||
`circuit.delete`, `circuit-device-row.update`,
|
`circuit.delete`, `circuit-device-row.update`,
|
||||||
`circuit-device-row.insert`, `circuit-device-row.delete`,
|
`circuit-device-row.insert`, `circuit-device-row.delete`,
|
||||||
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
|
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
|
||||||
`circuit.reorder-section`, `circuit.renumber-section` and
|
`circuit.reorder-section`, `circuit.reorder-sections`,
|
||||||
|
`circuit.renumber-section` and
|
||||||
`project-device.update`, `project-device.insert`, `project-device.delete` and
|
`project-device.update`, `project-device.insert`, `project-device.delete` and
|
||||||
`project-device.sync-rows`, all with schema version `1`. Other command types
|
`project-device.sync-rows` as well as `project.update-settings`, all with
|
||||||
|
schema version `1`. Other command types
|
||||||
are rejected. Insert commands contain the complete entity or circuit block
|
are rejected. Insert commands contain the complete entity or circuit block
|
||||||
with stable ids; delete commands include the expected parent identity. Circuit
|
with stable ids; delete commands include the expected parent identity. Circuit
|
||||||
snapshots contain zero, one or multiple complete device rows. Move commands
|
snapshots contain zero, one or multiple complete device rows. Move commands
|
||||||
contain each row's expected and target circuit plus its exact expected and
|
contain each row's expected and target circuit plus its exact expected and
|
||||||
target sort order. This makes deletion and moves undoable without generating
|
target sort order. This makes deletion and moves undoable without generating
|
||||||
replacement identities or renumbering circuits. Existing Circuit and
|
replacement identities or renumbering circuits. Existing Circuit and
|
||||||
CircuitDeviceRow cell edits plus standalone insert/delete actions in the editor
|
CircuitDeviceRow cell edits, standalone insert/delete actions, device-row
|
||||||
consume these endpoints. Move, reorder and renumber UI paths are still being
|
moves, reorders and explicit renumbering in the editor consume these endpoints.
|
||||||
migrated.
|
|
||||||
|
|
||||||
`circuit-device-row.move` targets existing circuits in the same circuit list.
|
`circuit-device-row.move` targets existing circuits in the same circuit list.
|
||||||
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
|
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
|
||||||
@@ -52,6 +89,11 @@ in the section. Each assignment records the expected and target `sortOrder`.
|
|||||||
The command and its inverse change no circuit field other than `sortOrder`;
|
The command and its inverse change no circuit field other than `sortOrder`;
|
||||||
equipment identifiers and complete device-row blocks remain unchanged.
|
equipment identifiers and complete device-row blocks remain unchanged.
|
||||||
|
|
||||||
|
`circuit.reorder-sections` contains one complete assignment block per affected
|
||||||
|
section. All blocks are validated before any write and commit or roll back
|
||||||
|
together as one revision and one undo step. The editor uses it when applying a
|
||||||
|
sorted view that changes multiple sections.
|
||||||
|
|
||||||
`circuit.renumber-section` is an explicit operation requiring one assignment
|
`circuit.renumber-section` is an explicit operation requiring one assignment
|
||||||
for every circuit in the section. Assignments contain expected and target
|
for every circuit in the section. Assignments contain expected and target
|
||||||
equipment identifiers. The store rejects stale values, duplicate targets and
|
equipment identifiers. The store rejects stale values, duplicate targets and
|
||||||
@@ -99,6 +141,18 @@ These write endpoints execute the typed `project-device.update`,
|
|||||||
returns `409 PROJECT_REVISION_CONFLICT`. Updating a project device still never
|
returns `409 PROJECT_REVISION_CONFLICT`. Updating a project device still never
|
||||||
synchronizes linked circuit rows implicitly.
|
synchronizes linked circuit rows implicitly.
|
||||||
|
|
||||||
|
### Project Settings
|
||||||
|
|
||||||
|
- `PUT /projects/:projectId`
|
||||||
|
- request:
|
||||||
|
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400 }`
|
||||||
|
- executes `project.update-settings` as one atomic revision
|
||||||
|
- response:
|
||||||
|
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
|
- persistent Undo/Redo restores both voltage values together
|
||||||
|
- unchanged values are rejected without creating a revision; a stale
|
||||||
|
revision returns `409 PROJECT_REVISION_CONFLICT`
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -127,7 +181,14 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
|||||||
### Distribution Board Setup
|
### Distribution Board Setup
|
||||||
|
|
||||||
- `POST /projects/:projectId/distribution-boards`
|
- `POST /projects/:projectId/distribution-boards`
|
||||||
- atomically creates the distribution board, its circuit list and all default circuit sections
|
- body: `{ "name": "UV-02", "expectedRevision": 12 }`
|
||||||
|
- executes `distribution-board.insert` with stable ids for the distribution
|
||||||
|
board, its circuit list and all four default circuit sections
|
||||||
|
- response:
|
||||||
|
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
|
- persistent Undo removes only the unchanged and still-empty generated
|
||||||
|
structure; Redo restores the same ids
|
||||||
|
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||||
|
|
||||||
### Tree Endpoint
|
### Tree Endpoint
|
||||||
|
|
||||||
@@ -169,82 +230,12 @@ Response sketch:
|
|||||||
- `GET /circuit-sections/:sectionId/next-identifier`
|
- `GET /circuit-sections/:sectionId/next-identifier`
|
||||||
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
||||||
|
|
||||||
Circuit and device-row field updates, standalone insertions and standalone
|
Circuit and device-row field updates, standalone insertions/deletions, single
|
||||||
deletions are available only as the corresponding versioned commands through
|
or bulk device-row moves, circuit reorders and explicit renumbering are
|
||||||
|
available only as the corresponding versioned commands through
|
||||||
`POST /projects/:projectId/commands`. There are no direct field-update PATCH,
|
`POST /projects/:projectId/commands`. There are no direct field-update PATCH,
|
||||||
structure POST or CircuitDeviceRow DELETE routes. The direct
|
structure POST, move, reorder, renumber, identifier-restore, Circuit DELETE or
|
||||||
`DELETE /circuits/:circuitId` route remains temporarily for the inverse of the
|
CircuitDeviceRow DELETE routes.
|
||||||
not-yet-migrated placeholder move UI and must not be used by new features.
|
|
||||||
|
|
||||||
### Move Device Row
|
|
||||||
|
|
||||||
- `PATCH /circuit-device-rows/:rowId/move`
|
|
||||||
- Purpose: move one row to existing circuit or to newly created circuit in target section.
|
|
||||||
|
|
||||||
Request sketch:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"targetCircuitId": "cir_target"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
or
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"targetSectionId": "sec_target",
|
|
||||||
"createNewCircuit": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bulk Move Device Rows
|
|
||||||
|
|
||||||
- `PATCH /circuit-device-rows/move-bulk`
|
|
||||||
- Purpose: move multiple rows in one command flow.
|
|
||||||
|
|
||||||
Request sketch:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"rowIds": ["row_1", "row_2"],
|
|
||||||
"targetCircuitId": "cir_target"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reorder Circuits
|
|
||||||
|
|
||||||
- `PATCH /circuit-sections/:sectionId/circuits/reorder`
|
|
||||||
- Purpose: persist explicit circuit order within one section.
|
|
||||||
|
|
||||||
Request sketch:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"orderedCircuitIds": ["cir_2", "cir_1", "cir_3"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Renumber Section
|
|
||||||
|
|
||||||
- `POST /circuit-sections/:sectionId/renumber`
|
|
||||||
- Purpose: explicit renumbering by section prefix; never implicit on move/sort.
|
|
||||||
|
|
||||||
### Safe Equipment Identifier Update
|
|
||||||
|
|
||||||
- `PATCH /circuit-sections/:sectionId/equipment-identifiers`
|
|
||||||
- Purpose: apply explicit per-circuit identifiers safely even with unique constraints.
|
|
||||||
|
|
||||||
Request sketch:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"identifiers": [
|
|
||||||
{ "circuitId": "cir_1", "equipmentIdentifier": "-2F1" },
|
|
||||||
{ "circuitId": "cir_2", "equipmentIdentifier": "-2F2" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Removed Legacy Endpoints
|
## Removed Legacy Endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -122,13 +122,16 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
|||||||
|
|
||||||
## Undo/Redo
|
## Undo/Redo
|
||||||
|
|
||||||
Undo/redo wraps editor operations as command objects and reloads the tree after
|
Editor operations execute persistent project commands and reload the tree after
|
||||||
each command. Existing Circuit and device-row cell edits plus standalone
|
each command. On initial load and every reload, the editor reads undo/redo
|
||||||
insert/delete actions execute persistent project commands; their toolbar
|
eligibility from the project-wide server history and verifies that tree and
|
||||||
undo/redo calls the project-wide server history. New entities receive stable
|
history belong to the same project revision. Undo/redo therefore remains
|
||||||
UUIDs before insertion, and deletion undo restores those same ids. Move,
|
available after a page refresh. New entities receive stable UUIDs before
|
||||||
reorder and renumber operations still use their existing direct API writes and
|
insertion, and deletion undo restores those same ids. Device-row moves and
|
||||||
session-local inverse callbacks during the cutover.
|
circuit reorders also use persistent project commands. Applying a sorted view
|
||||||
|
across multiple sections is atomic and occupies one history step. Explicit
|
||||||
|
renumbering uses a collision-safe persistent project command and one
|
||||||
|
project-history step.
|
||||||
|
|
||||||
Covered operations include:
|
Covered operations include:
|
||||||
|
|
||||||
@@ -139,8 +142,7 @@ Covered operations include:
|
|||||||
- renumber and identifier update flows
|
- renumber and identifier update flows
|
||||||
- apply sorted order
|
- apply sorted order
|
||||||
|
|
||||||
Current limitations:
|
The editor toolbar exposes availability through its Undo/Redo buttons. The
|
||||||
|
project page provides the same persistent project-wide controls in the header
|
||||||
- the visible editor stack is still session-local and is empty after reload
|
of its version card, including after a reload. Named restore points and the
|
||||||
- move, reorder and renumber editor actions are not connected to the persistent
|
revision timeline remain inside the expandable part of that card.
|
||||||
command boundary yet
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Circuit List Editor Known Limitations
|
# Circuit List Editor Known Limitations
|
||||||
|
|
||||||
- Existing Circuit and CircuitDeviceRow cell edits plus standalone
|
- All currently supported Circuit and CircuitDeviceRow editor writes use
|
||||||
insert/delete actions use persistent project commands and project-wide
|
persistent project commands and project-wide toolbar undo/redo, including
|
||||||
toolbar undo/redo. The editor's visible stack is still session-local, is
|
moves, atomic multi-section reorders and explicit renumbering. Undo/redo
|
||||||
empty after reload and still contains direct move/reorder/renumber operations
|
eligibility is restored from server history after a page reload.
|
||||||
during the cutover.
|
- The project page exposes the paginated revision timeline; the circuit editor
|
||||||
- Server-side project revisions and persistent undo/redo eligibility exist for
|
itself currently exposes only the next eligible Undo/Redo actions.
|
||||||
the command types documented in `circuit-list-editor-api.md`; complete editor
|
- Named and automatic logical project snapshots can be listed and restored
|
||||||
command coverage and history reconstruction are not complete.
|
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||||
- Named logical project snapshots and restore are not implemented yet.
|
created every 25 revisions and only their newest 12 entries are retained.
|
||||||
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
||||||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||||
- The global device library supports basic CRUD and copy operations, but has no
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
|
|||||||
@@ -62,28 +62,31 @@ liegt über einen Host-Mount außerhalb des Containers.
|
|||||||
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
|
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
|
||||||
Viewport soweit möglich wieder her.
|
Viewport soweit möglich wieder her.
|
||||||
|
|
||||||
Der sichtbare React-Historiestapel ist während des schrittweisen Cutovers noch
|
Der Editor besitzt keinen sitzungslokalen Undo-/Redo-Stapel mehr. Beim initialen
|
||||||
sitzungslokal. Das Datenmodell besitzt
|
Laden und nach jedem Tree-Reload liest er den persistenten History-Status und
|
||||||
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
|
gleicht dessen Revision mit `currentRevision` des Trees ab. Das Datenmodell
|
||||||
|
besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
|
||||||
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
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. Konkrete Fachkommandos und
|
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten
|
||||||
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
|
Circuit-, Gerätezeilen-, Projektgeräte- und Projekteinstellungsänderungen
|
||||||
angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne
|
verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes
|
||||||
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
|
inverses Kommando, Revision und Historienstapel gemeinsam committen
|
||||||
Revision gemeinsam committen beziehungsweise zurückrollen können.
|
beziehungsweise zurückrollen.
|
||||||
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
|
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
|
||||||
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
|
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
|
||||||
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
|
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
|
||||||
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
|
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
|
||||||
`GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt
|
`GET /api/projects/:projectId/history` lesbar. Die unveränderliche,
|
||||||
die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
|
absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über
|
||||||
öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind
|
`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler
|
||||||
`circuit-device-row.insert` und `circuit-device-row.delete` als atomare
|
Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über
|
||||||
Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im
|
öffentliche Command-, Undo- und Redo-Endpunkte aus.
|
||||||
inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
|
`circuit-device-row.insert` und `circuit-device-row.delete` sind atomare
|
||||||
wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
|
Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen
|
||||||
|
Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt.
|
||||||
|
`circuit.insert` und `circuit.delete` behandeln einen
|
||||||
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
|
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
|
||||||
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
|
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
|
||||||
Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
|
Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
|
||||||
@@ -91,10 +94,43 @@ sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über di
|
|||||||
öffentliche Command-Grenze. Neue Circuits und Gerätezeilen erhalten ihre stabile
|
öffentliche Command-Grenze. Neue Circuits und Gerätezeilen erhalten ihre stabile
|
||||||
UUID vor dem Command; Löschen und Undo bewahren diese Identität. Der Tree liefert
|
UUID vor dem Command; Löschen und Undo bewahren diese Identität. Der Tree liefert
|
||||||
dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite
|
dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite
|
||||||
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST- und Gerätezeilen-DELETE-
|
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und
|
||||||
Endpunkte sind entfernt. Move-, Reorder- und Renumber-UI-Pfade verbleiben
|
Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves,
|
||||||
vorerst im sichtbaren sitzungslokalen Stack; nach einem Reload wird dieser noch
|
Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die
|
||||||
nicht aus der Serverhistorie rekonstruiert.
|
persistenten Kommandos. Die Toolbar leitet ihre Undo-/Redo-Verfügbarkeit direkt
|
||||||
|
aus den serverseitigen Stack-Tiefen ab, sodass ein Reload die Bedienbarkeit
|
||||||
|
nicht unterbricht.
|
||||||
|
|
||||||
|
Benannte logische Projektstände werden in `project_snapshots` getrennt von
|
||||||
|
Datenbank-Backups gespeichert. `POST /api/projects/:projectId/snapshots`
|
||||||
|
erzeugt bei passender erwarteter Revision transaktional einen vollständigen,
|
||||||
|
schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind
|
||||||
|
Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und
|
||||||
|
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte,
|
||||||
|
Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots.
|
||||||
|
Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel.
|
||||||
|
`kind` unterscheidet benannte und automatische Stände. Die zentrale
|
||||||
|
Revisionspersistenz erzeugt nach jeweils 25 weiteren Projektänderungen
|
||||||
|
transaktional einen automatischen Stand. Pro Projekt bleiben die neuesten 12
|
||||||
|
automatischen Stände erhalten; ältere automatische Stände werden in derselben
|
||||||
|
Transaktion entfernt. Benannte Stände und die unveränderliche Revisionshistorie
|
||||||
|
sind von dieser Aufbewahrung ausdrücklich ausgeschlossen.
|
||||||
|
`POST /api/projects/:projectId/snapshots/:snapshotId/restore` prüft Payload,
|
||||||
|
Prüfsumme, erwartete Revision und den unmittelbar zuvor gelesenen
|
||||||
|
Projektzustand. Der Restore ersetzt alle unterstützten Projektdaten in einer
|
||||||
|
Transaktion und schreibt dabei eine neue Revision mit Quelle `restore` sowie
|
||||||
|
ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen
|
||||||
|
Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible
|
||||||
|
Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
|
||||||
|
nicht Bestandteil des logischen Snapshots sind.
|
||||||
|
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
|
||||||
|
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
|
||||||
|
benennen, nach expliziter Bestätigung wiederherstellen und die paginierte
|
||||||
|
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen.
|
||||||
|
Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im
|
||||||
|
eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den
|
||||||
|
persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt
|
||||||
|
die Seite sämtliche Projektdaten neu.
|
||||||
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
|
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
|
||||||
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
||||||
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
|
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
|
||||||
@@ -104,6 +140,9 @@ Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und
|
|||||||
BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten
|
BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten
|
||||||
Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen
|
Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen
|
||||||
Felder und vollständiger Zeilenbestand unverändert sind.
|
Felder und vollständiger Zeilenbestand unverändert sind.
|
||||||
|
Der Editor erzeugt die vollständigen Move-Zuweisungen aus dem geladenen Tree,
|
||||||
|
vergibt für neue Ziele vor dem Kommando eine stabile UUID und führt das
|
||||||
|
Toolbar-Undo/Redo über die projektweite Historie aus.
|
||||||
`circuit.reorder-section` speichert die erwartete und neue Sortierposition
|
`circuit.reorder-section` speichert die erwartete und neue Sortierposition
|
||||||
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
|
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
|
||||||
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
|
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
|
||||||
@@ -111,6 +150,15 @@ bleiben unverändert. `circuit.renumber-section` bildet die getrennte,
|
|||||||
ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und
|
ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und
|
||||||
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
|
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
|
||||||
ändert weder Sortierung noch Gerätezeilen.
|
ändert weder Sortierung noch Gerätezeilen.
|
||||||
|
Die Bereichsaktion erzeugt die vollständigen erwarteten und neuen BMKs aus
|
||||||
|
Präfix und aktueller Stromkreisreihenfolge und verwendet das persistente
|
||||||
|
Kommando für Toolbar-Undo/Redo. Die früheren direkten Renumber- und
|
||||||
|
Identifier-Restore-Endpunkte sowie ihr separater Transaktionsadapter sind
|
||||||
|
entfernt.
|
||||||
|
Drag-and-drop verwendet `circuit.reorder-section`. Die explizite Übernahme
|
||||||
|
einer sortierten Ansicht verwendet `circuit.reorder-sections`, damit alle
|
||||||
|
betroffenen Bereiche in einer Transaktion und als ein Undo-Schritt gespeichert
|
||||||
|
werden. Direkte Reorder-Endpunkte existieren nicht mehr.
|
||||||
`project-device.sync-rows` persistiert Synchronisierung, Trennen und erneutes
|
`project-device.sync-rows` persistiert Synchronisierung, Trennen und erneutes
|
||||||
Verknüpfen als atomaren Mehrzeilen-Command. Jede betroffene Zeile enthält den
|
Verknüpfen als atomaren Mehrzeilen-Command. Jede betroffene Zeile enthält den
|
||||||
vollständigen erwarteten und neuen Stand aller synchronisierbaren Felder,
|
vollständigen erwarteten und neuen Stand aller synchronisierbaren Felder,
|
||||||
@@ -118,8 +166,9 @@ einschließlich ProjectDevice-Verknüpfung und lokaler Override-Metadaten. Damit
|
|||||||
werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt
|
werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt
|
||||||
exakt die vorherigen lokalen Werte wieder her. Die Synchronisieren- und
|
exakt die vorherigen lokalen Werte wieder her. Die Synchronisieren- und
|
||||||
Trennen-Endpunkte sowie die Projektseite verwenden diesen Command mit
|
Trennen-Endpunkte sowie die Projektseite verwenden diesen Command mit
|
||||||
optimistischer Revisionsprüfung. Das dortige Rückgängig löst die persistente
|
optimistischer Revisionsprüfung. Rückgängig/Wiederholen erfolgt dort
|
||||||
projektweite Historie aus; separate direkte Restore-/Reconnect-Schreibwege
|
einheitlich über die persistente projektweite Historie; der frühere
|
||||||
|
sitzungslokale Spezial-Undo und separate direkte Restore-/Reconnect-Schreibwege
|
||||||
existieren nicht mehr.
|
existieren nicht mehr.
|
||||||
`project-device.update` versioniert Änderungen an den kanonischen
|
`project-device.update` versioniert Änderungen an den kanonischen
|
||||||
Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem
|
Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem
|
||||||
@@ -136,6 +185,19 @@ unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revisi
|
|||||||
und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
|
und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
|
||||||
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
||||||
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
|
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
|
||||||
|
`project.update-settings` versioniert die beiden Projekt-Standardspannungen als
|
||||||
|
eine atomare Änderung. Der Store leitet das inverse Kommando aus dem
|
||||||
|
gespeicherten Projekt ab und schreibt Werte, Revision und Historienstapel
|
||||||
|
gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb
|
||||||
|
`expectedRevision`, liefert Projekt plus aktualisierten Historienstand und
|
||||||
|
besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
||||||
|
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||||
|
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||||
|
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||||
|
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
|
||||||
|
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
|
||||||
|
Projektseite übergeben die erwartete Projektrevision; der frühere direkte
|
||||||
|
Controller-Schreibweg ist entfernt.
|
||||||
|
|
||||||
## Projektgeräte
|
## Projektgeräte
|
||||||
|
|
||||||
@@ -167,8 +229,6 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
|||||||
|
|
||||||
## Noch nicht unterstützt
|
## Noch nicht unterstützt
|
||||||
|
|
||||||
- vollständiges persistentes Undo/Redo, sichtbare Historie nach Reload und
|
|
||||||
benannte Projektstände
|
|
||||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||||
- Revit-/CSV-/IFCGUID-Round-trip
|
- Revit-/CSV-/IFCGUID-Round-trip
|
||||||
- vollständige elektrische Dimensionierung
|
- vollständige elektrische Dimensionierung
|
||||||
|
|||||||
@@ -130,27 +130,8 @@ Expected response shape:
|
|||||||
|
|
||||||
If migrations were not applied, endpoint may return an empty fallback with a warning.
|
If migrations were not applied, endpoint may return an empty fallback with a warning.
|
||||||
|
|
||||||
## Dev-only visual test helper (multi-device circuit)
|
## Visual verification
|
||||||
|
|
||||||
Add one extra manual device row to an existing circuit:
|
Manual device rows and multi-device circuits can be created directly in the
|
||||||
|
circuit-list editor. The former development scripts for direct database writes
|
||||||
```bash
|
were removed because they bypassed project revisions and persistent undo/redo.
|
||||||
npm run dev:add-manual-circuit-row -- <circuitId>
|
|
||||||
```
|
|
||||||
|
|
||||||
Default inserted values:
|
|
||||||
- `name`: `Test sub device`
|
|
||||||
- `displayName`: `Beleuchtung WC`
|
|
||||||
- `phaseType`: `single_phase`
|
|
||||||
- `quantity`: `1`
|
|
||||||
- `powerPerUnit`: `0.05`
|
|
||||||
- `simultaneityFactor`: `1`
|
|
||||||
- `cosPhi`: `1`
|
|
||||||
|
|
||||||
The script prints the created row id.
|
|
||||||
|
|
||||||
Delete the test row again:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev:delete-circuit-row -- <rowId>
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ Full event sourcing is not required. Current normalized tables remain the primar
|
|||||||
- After a page reload, the client requests the current revision and eligible history actions.
|
- After a page reload, the client requests the current revision and eligible history actions.
|
||||||
- Commands include an expected project revision. A stale command is rejected instead of silently overwriting a newer project state.
|
- Commands include an expected project revision. A stale command is rejected instead of silently overwriting a newer project state.
|
||||||
|
|
||||||
The current session-local frontend command stack is transitional. Editor modularization must separate command descriptions from React callbacks so commands can later be sent to and reconstructed by the server.
|
The circuit-list editor uses the server state directly for operation
|
||||||
|
eligibility. React callbacks only initiate forward commands and provide
|
||||||
|
best-effort selection hints after the tree reload.
|
||||||
|
|
||||||
## Snapshot Storage
|
## Snapshot Storage
|
||||||
|
|
||||||
@@ -73,6 +75,43 @@ Project history must use logical project data, not copies of the SQLite database
|
|||||||
|
|
||||||
Large snapshots may later be compressed or stored in object storage, with metadata and checksums retained in the database.
|
Large snapshots may later be compressed or stored in object storage, with metadata and checksums retained in the database.
|
||||||
|
|
||||||
|
Implemented named-snapshot foundation:
|
||||||
|
|
||||||
|
- `project_snapshots` stores project ownership, source revision, schema
|
||||||
|
version, name, optional description, logical payload, SHA-256 and creation
|
||||||
|
metadata
|
||||||
|
- snapshot creation requires the expected current revision and reads plus
|
||||||
|
stores the complete project state in one SQLite transaction
|
||||||
|
- the versioned payload contains project settings, distribution boards,
|
||||||
|
circuit lists, sections, circuits with device rows, project devices, floors
|
||||||
|
and rooms while excluding global and upgrade-only data
|
||||||
|
- snapshot creation/listing does not create a project revision or affect
|
||||||
|
undo/redo eligibility
|
||||||
|
- public Create/List endpoints expose metadata but not the logical payload
|
||||||
|
- restore verifies the stored checksum and a hash of the complete current
|
||||||
|
state before replacing supported project data in one transaction
|
||||||
|
- restore records a new `restore` revision containing both the selected target
|
||||||
|
state and the complete pre-restore inverse; undo/redo therefore remains
|
||||||
|
available after application restarts
|
||||||
|
- compatible upgrade-only Consumer links, mappings and reports are preserved
|
||||||
|
separately and are never imported into the logical snapshot payload
|
||||||
|
- arbitrary restore payloads are rejected by the generic user-command
|
||||||
|
endpoint; only server-stored snapshots can initiate a forward restore
|
||||||
|
- the project page provides an initially collapsed snapshot/timeline panel,
|
||||||
|
explicit inline restore confirmation and cursor-based loading of older
|
||||||
|
revisions; a successful restore reloads all project read models
|
||||||
|
|
||||||
|
Implemented automatic-snapshot policy:
|
||||||
|
|
||||||
|
- `project_snapshots.kind` distinguishes `named` from `automatic`
|
||||||
|
- the central revision boundary captures the complete post-command state after
|
||||||
|
every 25 additional project revisions
|
||||||
|
- the newest 12 automatic snapshots per project are retained
|
||||||
|
- retention never removes named snapshots, immutable revisions or physical
|
||||||
|
database backups
|
||||||
|
- automatic capture and retention run inside the same transaction as the
|
||||||
|
triggering project revision
|
||||||
|
|
||||||
## Revit / CSV / IFCGUID Round-Trip Direction
|
## Revit / CSV / IFCGUID Round-Trip Direction
|
||||||
|
|
||||||
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
||||||
@@ -168,6 +207,9 @@ Completed foundation:
|
|||||||
a forced stack failure rolls all of them back
|
a forced stack failure rolls all of them back
|
||||||
- `GET /api/projects/:projectId/history` exposes current revision, stack depths
|
- `GET /api/projects/:projectId/history` exposes current revision, stack depths
|
||||||
and top change-set ids
|
and top change-set ids
|
||||||
|
- `GET /api/projects/:projectId/history/revisions` exposes a descending,
|
||||||
|
cursor-paginated metadata timeline without leaking persisted forward or
|
||||||
|
inverse command payloads
|
||||||
- a central application dispatcher executes the supported `circuit.update`
|
- a central application dispatcher executes the supported `circuit.update`
|
||||||
and `circuit-device-row.update` envelopes without coupling the domain service
|
and `circuit-device-row.update` envelopes without coupling the domain service
|
||||||
to SQLite
|
to SQLite
|
||||||
@@ -194,12 +236,25 @@ Completed foundation:
|
|||||||
empty target-circuit snapshot; forward creation and all row moves share one
|
empty target-circuit snapshot; forward creation and all row moves share one
|
||||||
transaction, while undo restores the source positions and deletes only an
|
transaction, while undo restores the source positions and deletes only an
|
||||||
unchanged generated target
|
unchanged generated target
|
||||||
|
- the circuit-list editor executes existing-target and generated-target row
|
||||||
|
moves through these commands; obsolete direct move and Circuit DELETE routes
|
||||||
|
plus their separate transaction adapter are removed
|
||||||
- `circuit.reorder-section` requires the complete section circuit set and
|
- `circuit.reorder-section` requires the complete section circuit set and
|
||||||
stores exact expected/target sort positions; its inverse changes no
|
stores exact expected/target sort positions; its inverse changes no
|
||||||
equipment identifier and never splits a circuit block
|
equipment identifier and never splits a circuit block
|
||||||
|
- `circuit.reorder-sections` applies the same invariants to every affected
|
||||||
|
section from a sorted view in one transaction and one history entry
|
||||||
|
- circuit drag-and-drop and sorted-order application use these commands; the
|
||||||
|
obsolete direct reorder route and transaction method are removed
|
||||||
- `circuit.renumber-section` is the separate explicit renumber operation; it
|
- `circuit.renumber-section` is the separate explicit renumber operation; it
|
||||||
stores every expected/target equipment identifier, uses collision-safe
|
stores every expected/target equipment identifier, uses collision-safe
|
||||||
temporary values and restores the exact prior identifiers on undo
|
temporary values and restores the exact prior identifiers on undo
|
||||||
|
- the editor's explicit renumber action uses this command; obsolete direct
|
||||||
|
renumber and identifier-restore routes plus their transaction adapter are
|
||||||
|
removed
|
||||||
|
- the circuit-list editor loads project-wide undo/redo eligibility together
|
||||||
|
with the tree, retries revision mismatches and keeps Undo/Redo available after
|
||||||
|
page reloads without a session-local command stack
|
||||||
- `project-device.sync-rows` records synchronization, disconnect and reconnect
|
- `project-device.sync-rows` records synchronization, disconnect and reconnect
|
||||||
as one atomic multi-row operation; complete expected/target sync snapshots
|
as one atomic multi-row operation; complete expected/target sync snapshots
|
||||||
protect local values, links and override metadata against silent stale writes
|
protect local values, links and override metadata against silent stale writes
|
||||||
@@ -208,15 +263,16 @@ Completed foundation:
|
|||||||
- `project-device.insert` and `project-device.delete` preserve stable device
|
- `project-device.insert` and `project-device.delete` preserve stable device
|
||||||
ids; delete undo restores links only from complete unchanged disconnected-row
|
ids; delete undo restores links only from complete unchanged disconnected-row
|
||||||
snapshots in the same transaction
|
snapshots in the same transaction
|
||||||
|
- `project.update-settings` persists both project voltage defaults, its exact
|
||||||
|
inverse, revision and history transition atomically; the project page and
|
||||||
|
existing PUT route require the current expected revision
|
||||||
|
- `distribution-board.insert` persists the complete generated board, circuit
|
||||||
|
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
|
||||||
|
|
||||||
Remaining constraints before completing persistent history:
|
Remaining constraints before completing project version history:
|
||||||
|
|
||||||
- route the remaining move, reorder and renumber editor writes through their
|
|
||||||
existing typed persistent commands and remove the final direct mutation
|
|
||||||
routes
|
|
||||||
- reconstruct the visible frontend history state after reload; Circuit and
|
|
||||||
CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations
|
|
||||||
already use persistent commands and project-wide undo
|
|
||||||
- extend project-scoped commands only when further project mutations are
|
- extend project-scoped commands only when further project mutations are
|
||||||
deliberately added to history; the generic versioned envelope is complete
|
deliberately added to history; the generic versioned envelope is complete
|
||||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||||
@@ -224,7 +280,6 @@ Remaining constraints before completing persistent history:
|
|||||||
|
|
||||||
## Deferred Decisions
|
## Deferred Decisions
|
||||||
|
|
||||||
- exact snapshot frequency and retention policy
|
|
||||||
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
||||||
- branch visualization after undo followed by new edits
|
- branch visualization after undo followed by new edits
|
||||||
- user/role model and actor attribution
|
- user/role model and actor attribution
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ Implemented foundation:
|
|||||||
- a new circuit and all of its initial device rows use the same transaction store
|
- a new circuit and all of its initial device rows use the same transaction store
|
||||||
- device-row moves, reserve-state updates and optional target creation share one transaction
|
- device-row moves, reserve-state updates and optional target creation share one transaction
|
||||||
- project-device synchronization, disconnect and reconnect use a separate injected transaction store
|
- project-device synchronization, disconnect and reconnect use a separate injected transaction store
|
||||||
- section renumbering and circuit reordering use a separate injected transaction store
|
- section renumbering and circuit reordering use persistent command stores
|
||||||
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
||||||
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
|
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
|
||||||
- BMK swaps and section reorder have real SQLite commit and rollback coverage
|
- BMK swaps and section reorder have real SQLite commit and rollback coverage
|
||||||
@@ -358,14 +358,23 @@ Tasks:
|
|||||||
- [x] add immutable append-only server-side change-set storage with versioned
|
- [x] add immutable append-only server-side change-set storage with versioned
|
||||||
forward/inverse payloads
|
forward/inverse payloads
|
||||||
- [x] add optimistic checks when appending a revision
|
- [x] add optimistic checks when appending a revision
|
||||||
- route domain writes and history records through one shared transaction
|
- [x] route supported domain writes and history records through one shared transaction
|
||||||
- define concrete serializable domain command descriptions and executors
|
- [x] define concrete serializable domain command descriptions and executors
|
||||||
outside React
|
outside React
|
||||||
- [x] persist undo/redo eligibility on the server
|
- [x] persist undo/redo eligibility on the server
|
||||||
- [x] expose public command, undo and redo endpoints for the first supported
|
- [x] expose public command, undo and redo endpoints for the first supported
|
||||||
update command types
|
update command types
|
||||||
- add named and periodic logical project snapshots
|
- [x] expose a cursor-paginated, read-only revision metadata timeline without
|
||||||
- restore a historical snapshot as a new revision
|
command payloads
|
||||||
|
- [x] add explicitly named logical project snapshots with schema-versioned
|
||||||
|
payloads and integrity checksums
|
||||||
|
- [x] add periodic logical project snapshots with a bounded retention policy
|
||||||
|
- [x] restore a historical snapshot as a new auditable revision with
|
||||||
|
persistent undo/redo
|
||||||
|
- [x] expose named snapshots, confirmed restore and the paginated revision
|
||||||
|
timeline on the project page
|
||||||
|
- [x] expose persistent project-wide undo/redo controls on the project page
|
||||||
|
and remove its session-local ProjectDevice synchronization undo state
|
||||||
- keep database backups separate from logical history
|
- keep database backups separate from logical history
|
||||||
|
|
||||||
Implemented foundation:
|
Implemented foundation:
|
||||||
@@ -387,6 +396,18 @@ Implemented foundation:
|
|||||||
late-failure rollback coverage
|
late-failure rollback coverage
|
||||||
- a read-only project history endpoint exposes revision, stack depths and top
|
- a read-only project history endpoint exposes revision, stack depths and top
|
||||||
change-set ids
|
change-set ids
|
||||||
|
- a separate read-only timeline endpoint exposes immutable revision metadata
|
||||||
|
in descending, cursor-paginated pages without forward/inverse payloads
|
||||||
|
- named snapshot Create/List endpoints persist complete project-scoped runtime
|
||||||
|
state with the source revision and SHA-256 without changing revision or
|
||||||
|
undo/redo stacks
|
||||||
|
- the central revision boundary captures an `automatic` snapshot after every
|
||||||
|
25 new revisions and retains the newest 12 automatic snapshots per project
|
||||||
|
without deleting named snapshots or immutable revisions
|
||||||
|
- the project page lists, creates and explicitly restores named snapshots and
|
||||||
|
displays paginated revision metadata without exposing command payloads
|
||||||
|
- the version-card header reads persisted undo/redo eligibility and executes
|
||||||
|
optimistic project-wide undo/redo even while its timeline body is collapsed
|
||||||
- a central application dispatcher reconstructs persisted commands and exposes
|
- a central application dispatcher reconstructs persisted commands and exposes
|
||||||
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
|
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
|
||||||
field updates
|
field updates
|
||||||
@@ -424,6 +445,12 @@ Implemented foundation:
|
|||||||
- ProjectDevice synchronization and disconnect endpoints also require
|
- ProjectDevice synchronization and disconnect endpoints also require
|
||||||
`expectedRevision`; their UI undo uses project-wide persistent history and
|
`expectedRevision`; their UI undo uses project-wide persistent history and
|
||||||
the obsolete direct restore/reconnect write paths are removed
|
the obsolete direct restore/reconnect write paths are removed
|
||||||
|
- project voltage settings use `project.update-settings`; both values, inverse,
|
||||||
|
revision and history transition commit atomically and the former direct
|
||||||
|
settings write is removed
|
||||||
|
- 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
|
||||||
- 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
|
||||||
@@ -431,10 +458,13 @@ Implemented foundation:
|
|||||||
and rollback after a forced late persistence failure
|
and rollback after a forced late persistence failure
|
||||||
|
|
||||||
ProjectDevice CRUD, synchronization and disconnect on the project page and
|
ProjectDevice CRUD, synchronization and disconnect on the project page and
|
||||||
Circuit/CircuitDeviceRow field/insert/delete actions in the circuit-list editor
|
Circuit/CircuitDeviceRow field/insert/delete actions, device-row moves and
|
||||||
are connected to this history boundary. Move, reorder and renumber UI paths are
|
circuit reorders in the circuit-list editor are connected to this history
|
||||||
not yet connected; the editor's visible undo/redo stack therefore remains
|
boundary. Applying sorted order across multiple sections is one atomic command.
|
||||||
session-local and is not reconstructed after reload.
|
Explicit renumbering is connected to the same persistent boundary. The
|
||||||
|
editor reads the persistent undo/redo stack depths on initial load and after
|
||||||
|
every tree reload. Tree and history revisions are reconciled before the state
|
||||||
|
is exposed, so the toolbar remains usable after a page refresh.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -14,16 +14,14 @@
|
|||||||
"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-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.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-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.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-transaction.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-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-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-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.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-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.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-transaction.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-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",
|
||||||
"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",
|
||||||
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
||||||
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
|
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
|
||||||
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts",
|
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts"
|
||||||
"dev:add-manual-circuit-row": "tsx scripts/dev-add-manual-circuit-row.ts",
|
|
||||||
"dev:delete-circuit-row": "tsx scripts/dev-delete-circuit-row.ts"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { db } from "../src/db/client.js";
|
|
||||||
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
|
|
||||||
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const circuitId = process.argv[2];
|
|
||||||
if (!circuitId) {
|
|
||||||
console.error("Usage: npm run dev:add-manual-circuit-row -- <circuitId>");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const circuitRepository = new CircuitRepository();
|
|
||||||
const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db);
|
|
||||||
|
|
||||||
const circuit = await circuitRepository.findById(circuitId);
|
|
||||||
if (!circuit) {
|
|
||||||
console.error(`Circuit not found: ${circuitId}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdRowId = rowTransactionRepository.createInCircuit({
|
|
||||||
circuitId,
|
|
||||||
name: "Test sub device",
|
|
||||||
displayName: "Beleuchtung WC",
|
|
||||||
phaseType: "single_phase",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.05,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Created test row id: ${createdRowId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((error) => {
|
|
||||||
console.error("Failed to create test row:", error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { db } from "../src/db/client.js";
|
|
||||||
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
|
|
||||||
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const rowId = process.argv[2];
|
|
||||||
if (!rowId) {
|
|
||||||
console.error("Usage: npm run dev:delete-circuit-row -- <rowId>");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowRepository = new CircuitDeviceRowRepository();
|
|
||||||
const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db);
|
|
||||||
const row = await rowRepository.findById(rowId);
|
|
||||||
if (!row) {
|
|
||||||
console.error(`Row not found: ${rowId}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
rowTransactionRepository.deleteFromCircuit(rowId, row.circuitId);
|
|
||||||
console.log(`Deleted row id: ${rowId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((error) => {
|
|
||||||
console.error("Failed to delete row:", error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
listProjects,
|
listProjects,
|
||||||
listRooms,
|
listRooms,
|
||||||
synchronizeProjectDeviceRows,
|
synchronizeProjectDeviceRows,
|
||||||
undoProjectCommand,
|
|
||||||
updateProjectDevice,
|
updateProjectDevice,
|
||||||
updateProjectSettings,
|
updateProjectSettings,
|
||||||
} from "../../../frontend/utils/api";
|
} from "../../../frontend/utils/api";
|
||||||
@@ -40,6 +39,7 @@ import {
|
|||||||
projectDeviceSyncFields,
|
projectDeviceSyncFields,
|
||||||
type ProjectDeviceSyncField,
|
type ProjectDeviceSyncField,
|
||||||
} from "../../../shared/constants/project-device-sync-fields";
|
} from "../../../shared/constants/project-device-sync-fields";
|
||||||
|
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
|
||||||
|
|
||||||
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||||
name: "Technischer Name",
|
name: "Technischer Name",
|
||||||
@@ -55,11 +55,6 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
|||||||
remark: "Bemerkung",
|
remark: "Bemerkung",
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProjectDeviceUndoState = {
|
|
||||||
projectDeviceId: string;
|
|
||||||
revisionNumber: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyProjectDevice: CreateProjectDeviceInput = {
|
const emptyProjectDevice: CreateProjectDeviceInput = {
|
||||||
name: "",
|
name: "",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
@@ -116,7 +111,6 @@ export default function ProjectDetailPage() {
|
|||||||
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
||||||
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
||||||
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
||||||
const [projectDeviceUndo, setProjectDeviceUndo] = useState<ProjectDeviceUndoState | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
@@ -179,20 +173,27 @@ export default function ProjectDetailPage() {
|
|||||||
setProject((current) =>
|
setProject((current) =>
|
||||||
current ? { ...current, currentRevision } : current
|
current ? { ...current, currentRevision } : current
|
||||||
);
|
);
|
||||||
setProjectDeviceUndo(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
|
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!projectId || !boardName.trim()) {
|
if (!projectId || !project || !boardName.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const created = await createDistributionBoard(projectId, boardName.trim());
|
const result = await createDistributionBoard(
|
||||||
setBoards((current) => [...current, created]);
|
projectId,
|
||||||
|
boardName.trim(),
|
||||||
|
project.currentRevision
|
||||||
|
);
|
||||||
|
setBoards((current) => [
|
||||||
|
...current,
|
||||||
|
result.distributionBoard,
|
||||||
|
]);
|
||||||
setCircuitLists(await listCircuitLists(projectId));
|
setCircuitLists(await listCircuitLists(projectId));
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
setBoardName("");
|
setBoardName("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
|
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
|
||||||
@@ -244,17 +245,21 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveProjectSettings() {
|
async function handleSaveProjectSettings() {
|
||||||
if (!projectId) {
|
if (!projectId || !project) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await updateProjectSettings(projectId, {
|
const result = await updateProjectSettings(
|
||||||
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
projectId,
|
||||||
threePhaseVoltageV: Number(threePhaseVoltageV),
|
project.currentRevision,
|
||||||
});
|
{
|
||||||
setProject(updated);
|
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||||||
|
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setProject(result.project);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden.");
|
setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -381,7 +386,6 @@ export default function ProjectDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setProjectDeviceUndo(null);
|
|
||||||
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
|
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
|
||||||
setSyncPreview(preview);
|
setSyncPreview(preview);
|
||||||
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
||||||
@@ -433,10 +437,6 @@ export default function ProjectDetailPage() {
|
|||||||
setSelectedSyncRowIds(
|
setSelectedSyncRowIds(
|
||||||
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
|
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
|
||||||
);
|
);
|
||||||
setProjectDeviceUndo({
|
|
||||||
projectDeviceId: syncPreview.projectDevice.id,
|
|
||||||
revisionNumber: result.revision.revisionNumber,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
|
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -469,10 +469,6 @@ export default function ProjectDetailPage() {
|
|||||||
.map((row) => row.rowId)
|
.map((row) => row.rowId)
|
||||||
);
|
);
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
setProjectDeviceUndo({
|
|
||||||
projectDeviceId: syncPreview.projectDevice.id,
|
|
||||||
revisionNumber: result.revision.revisionNumber,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
|
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -480,40 +476,6 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUndoProjectDeviceOperation() {
|
|
||||||
if (!projectId || !project || !projectDeviceUndo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
project.currentRevision !==
|
|
||||||
projectDeviceUndo.revisionNumber
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Eine neuere Projektänderung verhindert dieses Rückgängig."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const result = await undoProjectCommand(
|
|
||||||
projectId,
|
|
||||||
project.currentRevision
|
|
||||||
);
|
|
||||||
const preview = await getProjectDeviceSyncPreview(
|
|
||||||
projectId,
|
|
||||||
projectDeviceUndo.projectDeviceId
|
|
||||||
);
|
|
||||||
applyProjectRevision(result.history.currentRevision);
|
|
||||||
setSyncPreview(preview);
|
|
||||||
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
|
||||||
setProjectDeviceUndo(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Die letzte Aktion konnte nicht rückgängig gemacht werden.");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCopyGlobalToProject() {
|
async function handleCopyGlobalToProject() {
|
||||||
if (!projectId || !project || !selectedGlobalDeviceId) {
|
if (!projectId || !project || !selectedGlobalDeviceId) {
|
||||||
return;
|
return;
|
||||||
@@ -598,7 +560,14 @@ export default function ProjectDetailPage() {
|
|||||||
className="btn btn-primary w-100"
|
className="btn btn-primary w-100"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSaveProjectSettings}
|
onClick={handleSaveProjectSettings}
|
||||||
disabled={isSaving}
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
!project ||
|
||||||
|
(Number(singlePhaseVoltageV) ===
|
||||||
|
project.singlePhaseVoltageV &&
|
||||||
|
Number(threePhaseVoltageV) ===
|
||||||
|
project.threePhaseVoltageV)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Projekteigenschaften speichern
|
Projekteigenschaften speichern
|
||||||
</button>
|
</button>
|
||||||
@@ -608,6 +577,16 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{project ? (
|
||||||
|
<section className="col-12">
|
||||||
|
<ProjectVersionHistory
|
||||||
|
currentRevision={project.currentRevision}
|
||||||
|
onProjectStateChange={() => window.location.reload()}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="col-12 col-lg-4">
|
<section className="col-12 col-lg-4">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
<div className="card-header">Neue Verteilung</div>
|
<div className="card-header">Neue Verteilung</div>
|
||||||
@@ -1141,16 +1120,6 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<div className="d-flex flex-wrap gap-2">
|
||||||
{projectDeviceUndo ? (
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-secondary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleUndoProjectDeviceOperation()}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
Letzte Aktion rückgängig
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE `project_snapshots` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`source_revision` integer NOT NULL,
|
||||||
|
`schema_version` integer NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`payload_json` text NOT NULL,
|
||||||
|
`payload_sha256` text NOT NULL,
|
||||||
|
`created_at_iso` text NOT NULL,
|
||||||
|
`created_by_actor_id` text,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `project_snapshots_project_created_idx` ON `project_snapshots` (`project_id`,`created_at_iso`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `project_snapshots_project_name_unique` ON `project_snapshots` (`project_id`,`name`);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `project_snapshots` ADD `kind` text DEFAULT 'named' NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX `project_snapshots_project_kind_revision_idx` ON `project_snapshots` (`project_id`,`kind`,`source_revision`);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,20 @@
|
|||||||
"when": 1784835703230,
|
"when": 1784835703230,
|
||||||
"tag": "0013_project_history_stacks",
|
"tag": "0013_project_history_stacks",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785009799979,
|
||||||
|
"tag": "0014_project_snapshots",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785014383032,
|
||||||
|
"tag": "0015_modern_madame_masque",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
automaticProjectSnapshotIntervalRevisions,
|
||||||
|
automaticProjectSnapshotRetentionCount,
|
||||||
|
shouldCaptureAutomaticProjectSnapshot,
|
||||||
|
} from "../../domain/models/project-snapshot-policy.model.js";
|
||||||
|
import { projectStateSnapshotSchemaVersion } from "../../domain/models/project-state-snapshot.model.js";
|
||||||
|
import type { ProjectSnapshotMetadata } from "../../domain/ports/project-snapshot.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { projectSnapshots } from "../schema/project-snapshots.js";
|
||||||
|
import { readProjectStateSnapshot } from "./project-state-snapshot.persistence.js";
|
||||||
|
|
||||||
|
interface CaptureAutomaticProjectSnapshotInput {
|
||||||
|
projectId: string;
|
||||||
|
currentRevision: number;
|
||||||
|
createdAtIso: string;
|
||||||
|
actorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureAutomaticProjectSnapshot(
|
||||||
|
database: AppDatabase,
|
||||||
|
input: CaptureAutomaticProjectSnapshotInput
|
||||||
|
): ProjectSnapshotMetadata | null {
|
||||||
|
const latest = database
|
||||||
|
.select({ sourceRevision: projectSnapshots.sourceRevision })
|
||||||
|
.from(projectSnapshots)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectSnapshots.projectId, input.projectId),
|
||||||
|
eq(projectSnapshots.kind, "automatic")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(projectSnapshots.sourceRevision))
|
||||||
|
.get();
|
||||||
|
if (
|
||||||
|
!shouldCaptureAutomaticProjectSnapshot(
|
||||||
|
input.currentRevision,
|
||||||
|
latest?.sourceRevision ?? null
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = readProjectStateSnapshot(database, input.projectId);
|
||||||
|
if (!current || current.currentRevision !== input.currentRevision) {
|
||||||
|
throw new Error(
|
||||||
|
"Automatic snapshot state does not match the appended revision."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const preferredName = `Automatisch · Revision ${input.currentRevision}`;
|
||||||
|
const conflictingName = database
|
||||||
|
.select({ id: projectSnapshots.id })
|
||||||
|
.from(projectSnapshots)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectSnapshots.projectId, input.projectId),
|
||||||
|
eq(projectSnapshots.name, preferredName)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
const metadata: ProjectSnapshotMetadata = {
|
||||||
|
id,
|
||||||
|
projectId: input.projectId,
|
||||||
|
sourceRevision: input.currentRevision,
|
||||||
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
|
kind: "automatic",
|
||||||
|
name: conflictingName
|
||||||
|
? `${preferredName} · ${id}`
|
||||||
|
: preferredName,
|
||||||
|
description: `Automatischer Sicherungspunkt nach jeweils ${automaticProjectSnapshotIntervalRevisions} Projektänderungen.`,
|
||||||
|
payloadSha256: current.payloadSha256,
|
||||||
|
createdAtIso: input.createdAtIso,
|
||||||
|
createdByActorId: input.actorId ?? null,
|
||||||
|
};
|
||||||
|
database
|
||||||
|
.insert(projectSnapshots)
|
||||||
|
.values({
|
||||||
|
...metadata,
|
||||||
|
payloadJson: current.payloadJson,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const automaticSnapshots = database
|
||||||
|
.select({ id: projectSnapshots.id })
|
||||||
|
.from(projectSnapshots)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectSnapshots.projectId, input.projectId),
|
||||||
|
eq(projectSnapshots.kind, "automatic")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
desc(projectSnapshots.sourceRevision),
|
||||||
|
desc(projectSnapshots.createdAtIso),
|
||||||
|
desc(projectSnapshots.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const expiredIds = automaticSnapshots
|
||||||
|
.slice(automaticProjectSnapshotRetentionCount)
|
||||||
|
.map((snapshot) => snapshot.id);
|
||||||
|
if (expiredIds.length) {
|
||||||
|
database
|
||||||
|
.delete(projectSnapshots)
|
||||||
|
.where(inArray(projectSnapshots.id, expiredIds))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
|
||||||
import type {
|
|
||||||
CircuitDeviceRowTransactionStore,
|
|
||||||
CreateCircuitDeviceRowTransactionInput,
|
|
||||||
CreateCircuitWithDeviceRowsTransactionInput,
|
|
||||||
MoveCircuitDeviceRowsTransactionInput,
|
|
||||||
} from "../../domain/ports/circuit-device-row-transaction.store.js";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
|
||||||
import { circuits } from "../schema/circuits.js";
|
|
||||||
import { toCircuitCreateValues } from "./circuit.persistence.js";
|
|
||||||
import {
|
|
||||||
toCircuitDeviceRowCreateValues,
|
|
||||||
} from "./circuit-device-row.persistence.js";
|
|
||||||
|
|
||||||
export class CircuitDeviceRowTransactionRepository
|
|
||||||
implements CircuitDeviceRowTransactionStore
|
|
||||||
{
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
|
||||||
|
|
||||||
createInCircuit(input: CreateCircuitDeviceRowTransactionInput) {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
this.database.transaction((tx) => {
|
|
||||||
const [circuit] = tx
|
|
||||||
.select({ id: circuits.id })
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.id, input.circuitId))
|
|
||||||
.limit(1)
|
|
||||||
.all();
|
|
||||||
if (!circuit) {
|
|
||||||
throw new Error("Der Stromkreis ist ungültig.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingRows = tx
|
|
||||||
.select({ sortOrder: circuitDeviceRows.sortOrder })
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(eq(circuitDeviceRows.circuitId, input.circuitId))
|
|
||||||
.all();
|
|
||||||
const lastSortOrder = existingRows.reduce(
|
|
||||||
(highest, row) => Math.max(highest, row.sortOrder),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const sortOrder = input.sortOrder ?? lastSortOrder + 10;
|
|
||||||
|
|
||||||
tx
|
|
||||||
.insert(circuitDeviceRows)
|
|
||||||
.values(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder }))
|
|
||||||
.run();
|
|
||||||
tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ isReserve: 0 })
|
|
||||||
.where(eq(circuits.id, input.circuitId))
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
createCircuitWithDeviceRows(input: CreateCircuitWithDeviceRowsTransactionInput) {
|
|
||||||
if (input.deviceRows.length === 0) {
|
|
||||||
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const circuitId = crypto.randomUUID();
|
|
||||||
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
|
|
||||||
this.database.transaction((tx) => {
|
|
||||||
tx
|
|
||||||
.insert(circuits)
|
|
||||||
.values(
|
|
||||||
toCircuitCreateValues(circuitId, {
|
|
||||||
...input.circuit,
|
|
||||||
isReserve: false,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
for (let index = 0; index < input.deviceRows.length; index += 1) {
|
|
||||||
const row = input.deviceRows[index];
|
|
||||||
tx
|
|
||||||
.insert(circuitDeviceRows)
|
|
||||||
.values(
|
|
||||||
toCircuitDeviceRowCreateValues(rowIds[index], {
|
|
||||||
...row,
|
|
||||||
circuitId,
|
|
||||||
sortOrder: row.sortOrder ?? (index + 1) * 10,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { circuitId, rowIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteFromCircuit(rowId: string, expectedCircuitId: string) {
|
|
||||||
this.database.transaction((tx) => {
|
|
||||||
const [row] = tx
|
|
||||||
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(eq(circuitDeviceRows.id, rowId))
|
|
||||||
.limit(1)
|
|
||||||
.all();
|
|
||||||
if (!row || row.circuitId !== expectedCircuitId) {
|
|
||||||
throw new Error("Die Gerätezeile wurde vor dem Löschen verändert.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = tx
|
|
||||||
.delete(circuitDeviceRows)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuitDeviceRows.id, rowId),
|
|
||||||
eq(circuitDeviceRows.circuitId, expectedCircuitId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error("Die Gerätezeile konnte nicht gelöscht werden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingRows = tx
|
|
||||||
.select({ id: circuitDeviceRows.id })
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
|
|
||||||
.all();
|
|
||||||
tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
|
|
||||||
.where(eq(circuits.id, expectedCircuitId))
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
moveRows(input: MoveCircuitDeviceRowsTransactionInput) {
|
|
||||||
if (input.rows.length === 0) {
|
|
||||||
throw new Error("Keine Gerätezeilen zum Verschieben angegeben.");
|
|
||||||
}
|
|
||||||
if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) {
|
|
||||||
throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowIds = input.rows.map((row) => row.id);
|
|
||||||
if (new Set(rowIds).size !== rowIds.length) {
|
|
||||||
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined;
|
|
||||||
const targetCircuitId = input.targetCircuitId ?? createdCircuitId!;
|
|
||||||
|
|
||||||
this.database.transaction((tx) => {
|
|
||||||
const persistedRows = tx
|
|
||||||
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
|
||||||
.all();
|
|
||||||
if (persistedRows.length !== input.rows.length) {
|
|
||||||
throw new Error("Eine oder mehrere Gerätezeilen sind ungültig.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row]));
|
|
||||||
for (const expectedRow of input.rows) {
|
|
||||||
if (
|
|
||||||
persistedRowsById.get(expectedRow.id)?.circuitId !==
|
|
||||||
expectedRow.expectedCircuitId
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetCircuit: { id: string; circuitListId: string };
|
|
||||||
if (input.createTargetCircuit) {
|
|
||||||
tx
|
|
||||||
.insert(circuits)
|
|
||||||
.values({
|
|
||||||
id: targetCircuitId,
|
|
||||||
circuitListId: input.createTargetCircuit.circuitListId,
|
|
||||||
sectionId: input.createTargetCircuit.sectionId,
|
|
||||||
equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier,
|
|
||||||
displayName: input.createTargetCircuit.displayName,
|
|
||||||
sortOrder: input.createTargetCircuit.sortOrder,
|
|
||||||
isReserve: 0,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
targetCircuit = {
|
|
||||||
id: targetCircuitId,
|
|
||||||
circuitListId: input.createTargetCircuit.circuitListId,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const [persistedTargetCircuit] = tx
|
|
||||||
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.id, targetCircuitId))
|
|
||||||
.limit(1)
|
|
||||||
.all();
|
|
||||||
if (!persistedTargetCircuit) {
|
|
||||||
throw new Error("Der Zielstromkreis ist ungültig.");
|
|
||||||
}
|
|
||||||
targetCircuit = persistedTargetCircuit;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceCircuitIds = [
|
|
||||||
...new Set(input.rows.map((row) => row.expectedCircuitId)),
|
|
||||||
];
|
|
||||||
const sourceCircuits = tx
|
|
||||||
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
|
|
||||||
.from(circuits)
|
|
||||||
.where(inArray(circuits.id, sourceCircuitIds))
|
|
||||||
.all();
|
|
||||||
if (sourceCircuits.length !== sourceCircuitIds.length) {
|
|
||||||
throw new Error("Einer oder mehrere Quellstromkreise sind ungültig.");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
sourceCircuits.some(
|
|
||||||
(circuit) => circuit.circuitListId !== targetCircuit.circuitListId
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const movedRowIdSet = new Set(rowIds);
|
|
||||||
const retainedTargetRows = tx
|
|
||||||
.select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder })
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(eq(circuitDeviceRows.circuitId, targetCircuitId))
|
|
||||||
.all()
|
|
||||||
.filter((row) => !movedRowIdSet.has(row.id));
|
|
||||||
const lastTargetSortOrder = retainedTargetRows.reduce(
|
|
||||||
(highest, row) => Math.max(highest, row.sortOrder),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let index = 0; index < input.rows.length; index += 1) {
|
|
||||||
const row = input.rows[index];
|
|
||||||
const result = tx
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set({
|
|
||||||
circuitId: targetCircuitId,
|
|
||||||
sortOrder: lastTargetSortOrder + (index + 1) * 10,
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuitDeviceRows.id, row.id),
|
|
||||||
eq(circuitDeviceRows.circuitId, row.expectedCircuitId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sourceCircuitId of sourceCircuitIds) {
|
|
||||||
const remainingRows = tx
|
|
||||||
.select({ id: circuitDeviceRows.id })
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(eq(circuitDeviceRows.circuitId, sourceCircuitId))
|
|
||||||
.all();
|
|
||||||
tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
|
|
||||||
.where(eq(circuits.id, sourceCircuitId))
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ isReserve: 0 })
|
|
||||||
.where(eq(circuits.id, targetCircuitId))
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
movedRowIds: rowIds,
|
|
||||||
targetCircuitId,
|
|
||||||
createdCircuitId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
assertCircuitSectionReorderProjectCommand,
|
assertCircuitSectionReorderProjectCommand,
|
||||||
|
circuitSectionReorderCommandType,
|
||||||
createCircuitSectionReorderProjectCommand,
|
createCircuitSectionReorderProjectCommand,
|
||||||
|
type CircuitSectionReorderAssignment,
|
||||||
|
type CircuitSectionReorderProjectCommand,
|
||||||
} from "../../domain/models/circuit-section-reorder-project-command.model.js";
|
} from "../../domain/models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitSectionsReorderProjectCommand,
|
||||||
|
circuitSectionsReorderCommandType,
|
||||||
|
createCircuitSectionsReorderProjectCommand,
|
||||||
|
type CircuitSectionsReorderProjectCommand,
|
||||||
|
type CircuitSectionsReorderSection,
|
||||||
|
} from "../../domain/models/circuit-sections-reorder-project-command.model.js";
|
||||||
import type {
|
import type {
|
||||||
CircuitSectionReorderProjectCommandStore,
|
CircuitSectionReorderProjectCommandStore,
|
||||||
ExecuteCircuitSectionReorderCommandInput,
|
ExecuteCircuitSectionReorderCommandInput,
|
||||||
@@ -14,106 +24,49 @@ import { circuits } from "../schema/circuits.js";
|
|||||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
type ReorderHistoryCommand =
|
||||||
|
| CircuitSectionReorderProjectCommand
|
||||||
|
| CircuitSectionsReorderProjectCommand;
|
||||||
|
|
||||||
export class CircuitSectionReorderProjectCommandRepository
|
export class CircuitSectionReorderProjectCommandRepository
|
||||||
implements CircuitSectionReorderProjectCommandStore
|
implements CircuitSectionReorderProjectCommandStore
|
||||||
{
|
{
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
execute(input: ExecuteCircuitSectionReorderCommandInput) {
|
execute(input: ExecuteCircuitSectionReorderCommandInput) {
|
||||||
assertCircuitSectionReorderProjectCommand(input.command);
|
this.assertSupportedCommand(input.command);
|
||||||
|
|
||||||
return this.database.transaction((tx) => {
|
return this.database.transaction((tx) => {
|
||||||
const section = tx
|
const commandSections = this.getCommandSections(input.command);
|
||||||
.select({
|
const inverseSections = commandSections.map((section) => {
|
||||||
id: circuitSections.id,
|
this.assertCompleteCurrentSection(
|
||||||
circuitListId: circuitSections.circuitListId,
|
tx,
|
||||||
projectId: circuitLists.projectId,
|
input.projectId,
|
||||||
})
|
section
|
||||||
.from(circuitSections)
|
|
||||||
.innerJoin(
|
|
||||||
circuitLists,
|
|
||||||
eq(circuitLists.id, circuitSections.circuitListId)
|
|
||||||
)
|
|
||||||
.where(eq(circuitSections.id, input.command.payload.sectionId))
|
|
||||||
.get();
|
|
||||||
if (!section || section.projectId !== input.projectId) {
|
|
||||||
throw new Error(
|
|
||||||
"Circuit section does not belong to project."
|
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
sectionId: section.sectionId,
|
||||||
|
assignments: section.assignments.map((assignment) => ({
|
||||||
|
circuitId: assignment.circuitId,
|
||||||
|
expectedSortOrder: assignment.targetSortOrder,
|
||||||
|
targetSortOrder: assignment.expectedSortOrder,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const section of commandSections) {
|
||||||
|
this.applyAssignments(tx, section);
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistedCircuits = tx
|
const inverse =
|
||||||
.select({
|
input.command.type === circuitSectionReorderCommandType
|
||||||
id: circuits.id,
|
? createCircuitSectionReorderProjectCommand(
|
||||||
circuitListId: circuits.circuitListId,
|
inverseSections[0].sectionId,
|
||||||
sortOrder: circuits.sortOrder,
|
inverseSections[0].assignments
|
||||||
})
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.sectionId, section.id))
|
|
||||||
.all();
|
|
||||||
const assignments = input.command.payload.assignments;
|
|
||||||
if (
|
|
||||||
persistedCircuits.length !== assignments.length ||
|
|
||||||
persistedCircuits.some(
|
|
||||||
(circuit) =>
|
|
||||||
circuit.circuitListId !== section.circuitListId
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Circuit reorder must include every circuit in the section."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const circuitsById = new Map(
|
|
||||||
persistedCircuits.map((circuit) => [circuit.id, circuit])
|
|
||||||
);
|
|
||||||
for (const assignment of assignments) {
|
|
||||||
const circuit = circuitsById.get(assignment.circuitId);
|
|
||||||
if (
|
|
||||||
!circuit ||
|
|
||||||
circuit.sortOrder !== assignment.expectedSortOrder
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Circuit changed before section reorder execution."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inverse = createCircuitSectionReorderProjectCommand(
|
|
||||||
section.id,
|
|
||||||
assignments.map((assignment) => ({
|
|
||||||
circuitId: assignment.circuitId,
|
|
||||||
expectedSortOrder: assignment.targetSortOrder,
|
|
||||||
targetSortOrder: assignment.expectedSortOrder,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const assignment of assignments) {
|
|
||||||
if (
|
|
||||||
assignment.expectedSortOrder === assignment.targetSortOrder
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const updated = tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ sortOrder: assignment.targetSortOrder })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuits.id, assignment.circuitId),
|
|
||||||
eq(circuits.sectionId, section.id),
|
|
||||||
eq(
|
|
||||||
circuits.sortOrder,
|
|
||||||
assignment.expectedSortOrder
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
: createCircuitSectionsReorderProjectCommand(
|
||||||
.run();
|
inverseSections
|
||||||
if (updated.changes !== 1) {
|
);
|
||||||
throw new Error(
|
|
||||||
"Circuit changed during section reorder execution."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const revision = appendProjectRevision(tx, {
|
const revision = appendProjectRevision(tx, {
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
expectedRevision: input.expectedRevision,
|
expectedRevision: input.expectedRevision,
|
||||||
@@ -132,4 +85,125 @@ export class CircuitSectionReorderProjectCommandRepository
|
|||||||
return { revision, inverse };
|
return { revision, inverse };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertSupportedCommand(
|
||||||
|
command: ReorderHistoryCommand
|
||||||
|
) {
|
||||||
|
if (command.type === circuitSectionReorderCommandType) {
|
||||||
|
assertCircuitSectionReorderProjectCommand(command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.type === circuitSectionsReorderCommandType) {
|
||||||
|
assertCircuitSectionsReorderProjectCommand(command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported circuit section reorder command.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCommandSections(
|
||||||
|
command: ReorderHistoryCommand
|
||||||
|
): CircuitSectionsReorderSection[] {
|
||||||
|
if (command.type === circuitSectionReorderCommandType) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sectionId: command.payload.sectionId,
|
||||||
|
assignments: command.payload.assignments,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return command.payload.sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCompleteCurrentSection(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
sectionCommand: CircuitSectionsReorderSection
|
||||||
|
) {
|
||||||
|
const section = database
|
||||||
|
.select({
|
||||||
|
id: circuitSections.id,
|
||||||
|
circuitListId: circuitSections.circuitListId,
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
})
|
||||||
|
.from(circuitSections)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuitSections.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuitSections.id, sectionCommand.sectionId))
|
||||||
|
.get();
|
||||||
|
if (!section || section.projectId !== projectId) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section does not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedCircuits = database
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
circuitListId: circuits.circuitListId,
|
||||||
|
sortOrder: circuits.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.sectionId, section.id))
|
||||||
|
.all();
|
||||||
|
const assignments = sectionCommand.assignments;
|
||||||
|
if (
|
||||||
|
persistedCircuits.length !== assignments.length ||
|
||||||
|
persistedCircuits.some(
|
||||||
|
(circuit) =>
|
||||||
|
circuit.circuitListId !== section.circuitListId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit reorder must include every circuit in the section."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const circuitsById = new Map(
|
||||||
|
persistedCircuits.map((circuit) => [circuit.id, circuit])
|
||||||
|
);
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
const circuit = circuitsById.get(assignment.circuitId);
|
||||||
|
if (
|
||||||
|
!circuit ||
|
||||||
|
circuit.sortOrder !== assignment.expectedSortOrder
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit changed before section reorder execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAssignments(
|
||||||
|
database: AppDatabase,
|
||||||
|
sectionCommand: {
|
||||||
|
sectionId: string;
|
||||||
|
assignments: CircuitSectionReorderAssignment[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
for (const assignment of sectionCommand.assignments) {
|
||||||
|
if (
|
||||||
|
assignment.expectedSortOrder === assignment.targetSortOrder
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = database
|
||||||
|
.update(circuits)
|
||||||
|
.set({ sortOrder: assignment.targetSortOrder })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.id, assignment.circuitId),
|
||||||
|
eq(circuits.sectionId, sectionCommand.sectionId),
|
||||||
|
eq(circuits.sortOrder, assignment.expectedSortOrder)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit changed during section reorder execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
|
||||||
import type { CircuitSectionTransactionStore } from "../../domain/ports/circuit-section-transaction.store.js";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import { circuits } from "../schema/circuits.js";
|
|
||||||
|
|
||||||
export class CircuitSectionTransactionRepository
|
|
||||||
implements CircuitSectionTransactionStore
|
|
||||||
{
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
|
||||||
|
|
||||||
updateSortOrders(sectionId: string, orderedCircuitIds: string[]) {
|
|
||||||
if (orderedCircuitIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (new Set(orderedCircuitIds).size !== orderedCircuitIds.length) {
|
|
||||||
throw new Error(
|
|
||||||
"Stromkreise dürfen in der Reihenfolge nicht mehrfach vorkommen."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.database.transaction((tx) => {
|
|
||||||
const sectionCircuits = tx
|
|
||||||
.select({ id: circuits.id })
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.sectionId, sectionId))
|
|
||||||
.all();
|
|
||||||
const sectionCircuitIds = new Set(
|
|
||||||
sectionCircuits.map((circuit) => circuit.id)
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
sectionCircuits.length !== orderedCircuitIds.length ||
|
|
||||||
orderedCircuitIds.some((circuitId) => !sectionCircuitIds.has(circuitId))
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Die Reihenfolge muss alle Stromkreise des Bereichs enthalten."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < orderedCircuitIds.length; index += 1) {
|
|
||||||
const result = tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ sortOrder: (index + 1) * 10 })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuits.sectionId, sectionId),
|
|
||||||
eq(circuits.id, orderedCircuitIds[index])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
"Ein Stromkreis wurde vor Abschluss der Sortierung verändert."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateEquipmentIdentifiers(
|
|
||||||
circuitListId: string,
|
|
||||||
updates: Array<{ id: string; equipmentIdentifier: string }>,
|
|
||||||
tempNamespace: string
|
|
||||||
) {
|
|
||||||
if (updates.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.database.transaction((tx) => {
|
|
||||||
const ids = updates.map((entry) => entry.id);
|
|
||||||
const existing = tx
|
|
||||||
.select({ id: circuits.id })
|
|
||||||
.from(circuits)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuits.circuitListId, circuitListId),
|
|
||||||
inArray(circuits.id, ids)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
if (existing.length !== ids.length) {
|
|
||||||
throw new Error(
|
|
||||||
"One or more circuit ids are invalid for circuit list."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stamp = Date.now();
|
|
||||||
for (let index = 0; index < updates.length; index += 1) {
|
|
||||||
const entry = updates[index];
|
|
||||||
const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`;
|
|
||||||
tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ equipmentIdentifier: tempIdentifier })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuits.circuitListId, circuitListId),
|
|
||||||
eq(circuits.id, entry.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of updates) {
|
|
||||||
tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ equipmentIdentifier: entry.equipmentIdentifier })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuits.circuitListId, circuitListId),
|
|
||||||
eq(circuits.id, entry.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
|
||||||
export function createDefaultCircuitSectionValues(circuitListId: string) {
|
export function createDefaultCircuitSectionValues(circuitListId: string) {
|
||||||
return [
|
return defaultCircuitSectionDefinitions.map((entry) => ({
|
||||||
{ key: "lighting", displayName: "Lighting", prefix: "-1F", sortOrder: 10 },
|
|
||||||
{ key: "single_phase", displayName: "Single-phase circuits", prefix: "-2F", sortOrder: 20 },
|
|
||||||
{ key: "three_phase", displayName: "Three-phase circuits", prefix: "-3F", sortOrder: 30 },
|
|
||||||
{ key: "unassigned", displayName: "Unassigned", prefix: "-UF", sortOrder: 90 },
|
|
||||||
].map((entry) => ({
|
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
...entry,
|
...entry,
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertDistributionBoardDeleteProjectCommand,
|
||||||
|
assertDistributionBoardInsertProjectCommand,
|
||||||
|
createDistributionBoardDeleteProjectCommand,
|
||||||
|
createDistributionBoardInsertProjectCommand,
|
||||||
|
distributionBoardDeleteCommandType,
|
||||||
|
distributionBoardInsertCommandType,
|
||||||
|
type DistributionBoardStructureProjectCommand,
|
||||||
|
type DistributionBoardStructureSnapshot,
|
||||||
|
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
DistributionBoardStructureProjectCommandStore,
|
||||||
|
ExecuteDistributionBoardStructureCommandInput,
|
||||||
|
} from "../../domain/ports/distribution-board-structure-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
export class DistributionBoardStructureProjectCommandRepository
|
||||||
|
implements DistributionBoardStructureProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteDistributionBoardStructureCommandInput) {
|
||||||
|
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: DistributionBoardStructureProjectCommand
|
||||||
|
): DistributionBoardStructureProjectCommand {
|
||||||
|
if (command.type === distributionBoardInsertCommandType) {
|
||||||
|
assertDistributionBoardInsertProjectCommand(command);
|
||||||
|
return this.insert(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
command.payload.structure
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command.type === distributionBoardDeleteCommandType) {
|
||||||
|
assertDistributionBoardDeleteProjectCommand(command);
|
||||||
|
return this.delete(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
command.payload.structure
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported distribution-board structure command.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private insert(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
structure: DistributionBoardStructureSnapshot
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
structure.distributionBoard.projectId !== projectId ||
|
||||||
|
structure.circuitList.projectId !== projectId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Distribution-board structure does not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const project = database
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project not found.");
|
||||||
|
}
|
||||||
|
const existingBoard = database
|
||||||
|
.select({ id: distributionBoards.id })
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.id, structure.distributionBoard.id))
|
||||||
|
.get();
|
||||||
|
const existingList = database
|
||||||
|
.select({ id: circuitLists.id })
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(eq(circuitLists.id, structure.circuitList.id))
|
||||||
|
.get();
|
||||||
|
const existingSection = database
|
||||||
|
.select({ id: circuitSections.id })
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
circuitSections.id,
|
||||||
|
structure.sections.map((section) => section.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (existingBoard || existingList || existingSection) {
|
||||||
|
throw new Error("Distribution-board structure id already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
database
|
||||||
|
.insert(distributionBoards)
|
||||||
|
.values(structure.distributionBoard)
|
||||||
|
.run();
|
||||||
|
database.insert(circuitLists).values(structure.circuitList).run();
|
||||||
|
database.insert(circuitSections).values(structure.sections).run();
|
||||||
|
return createDistributionBoardDeleteProjectCommand(structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private delete(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
structure: DistributionBoardStructureSnapshot
|
||||||
|
) {
|
||||||
|
const board = database
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.id, structure.distributionBoard.id))
|
||||||
|
.get();
|
||||||
|
const list = database
|
||||||
|
.select()
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(eq(circuitLists.id, structure.circuitList.id))
|
||||||
|
.get();
|
||||||
|
const sections = database
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(
|
||||||
|
eq(circuitSections.circuitListId, structure.circuitList.id)
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
asc(circuitSections.sortOrder),
|
||||||
|
asc(circuitSections.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
if (
|
||||||
|
!board ||
|
||||||
|
!list ||
|
||||||
|
board.projectId !== projectId ||
|
||||||
|
!structureMatches(structure, { board, list, sections })
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Distribution-board structure changed before deletion."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const populatedCircuit = database
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.circuitListId, structure.circuitList.id))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (populatedCircuit) {
|
||||||
|
throw new Error(
|
||||||
|
"A populated distribution board cannot be removed by history."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = database
|
||||||
|
.delete(distributionBoards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
distributionBoards.id,
|
||||||
|
structure.distributionBoard.id
|
||||||
|
),
|
||||||
|
eq(distributionBoards.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (deleted.changes !== 1) {
|
||||||
|
throw new Error("Distribution board could not be deleted.");
|
||||||
|
}
|
||||||
|
return createDistributionBoardInsertProjectCommand(structure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function structureMatches(
|
||||||
|
expected: DistributionBoardStructureSnapshot,
|
||||||
|
actual: {
|
||||||
|
board: typeof distributionBoards.$inferSelect;
|
||||||
|
list: typeof circuitLists.$inferSelect;
|
||||||
|
sections: Array<typeof circuitSections.$inferSelect>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!sameRecord(expected.distributionBoard, actual.board) ||
|
||||||
|
!sameRecord(expected.circuitList, actual.list) ||
|
||||||
|
expected.sections.length !== actual.sections.length
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expectedSections = [...expected.sections].sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.sortOrder - right.sortOrder ||
|
||||||
|
left.id.localeCompare(right.id)
|
||||||
|
);
|
||||||
|
return expectedSections.every((section, index) =>
|
||||||
|
sameRecord(section, actual.sections[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRecord(
|
||||||
|
expected: Record<string, unknown>,
|
||||||
|
actual: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const expectedEntries = Object.entries(expected);
|
||||||
|
return (
|
||||||
|
expectedEntries.length === Object.keys(actual).length &&
|
||||||
|
expectedEntries.every(
|
||||||
|
([key, value]) => actual[key] === value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export function applyProjectHistoryTransition(
|
|||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
input: ApplyProjectHistoryTransitionInput
|
input: ApplyProjectHistoryTransitionInput
|
||||||
) {
|
) {
|
||||||
if (input.source === "user") {
|
if (input.source === "user" || input.source === "restore") {
|
||||||
database
|
database
|
||||||
.delete(projectHistoryStackEntries)
|
.delete(projectHistoryStackEntries)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { and, asc, desc, eq } from "drizzle-orm";
|
import { and, asc, desc, eq, lt } from "drizzle-orm";
|
||||||
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
|
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
|
||||||
import type {
|
import type {
|
||||||
ProjectHistoryCommand,
|
ProjectHistoryCommand,
|
||||||
ProjectHistoryDirection,
|
ProjectHistoryDirection,
|
||||||
|
ListProjectRevisionsInput,
|
||||||
|
ProjectRevisionPage,
|
||||||
ProjectHistoryState,
|
ProjectHistoryState,
|
||||||
ProjectHistoryStore,
|
ProjectHistoryStore,
|
||||||
} from "../../domain/ports/project-history.store.js";
|
} from "../../domain/ports/project-history.store.js";
|
||||||
|
import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
|
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
|
||||||
@@ -47,6 +50,74 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listRevisions(
|
||||||
|
input: ListProjectRevisionsInput
|
||||||
|
): ProjectRevisionPage | null {
|
||||||
|
validateRevisionPageInput(input);
|
||||||
|
const project = this.database
|
||||||
|
.select({ currentRevision: projects.currentRevision })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, input.projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectCondition = eq(
|
||||||
|
projectRevisions.projectId,
|
||||||
|
input.projectId
|
||||||
|
);
|
||||||
|
const rows = this.database
|
||||||
|
.select({
|
||||||
|
revisionId: projectRevisions.id,
|
||||||
|
changeSetId: projectChangeSets.id,
|
||||||
|
revisionNumber: projectRevisions.revisionNumber,
|
||||||
|
createdAtIso: projectRevisions.createdAtIso,
|
||||||
|
actorId: projectRevisions.actorId,
|
||||||
|
source: projectRevisions.source,
|
||||||
|
description: projectRevisions.description,
|
||||||
|
commandType: projectChangeSets.commandType,
|
||||||
|
payloadSchemaVersion: projectChangeSets.payloadSchemaVersion,
|
||||||
|
})
|
||||||
|
.from(projectRevisions)
|
||||||
|
.innerJoin(
|
||||||
|
projectChangeSets,
|
||||||
|
eq(
|
||||||
|
projectChangeSets.projectRevisionId,
|
||||||
|
projectRevisions.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
input.beforeRevision === undefined
|
||||||
|
? projectCondition
|
||||||
|
: and(
|
||||||
|
projectCondition,
|
||||||
|
lt(
|
||||||
|
projectRevisions.revisionNumber,
|
||||||
|
input.beforeRevision
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(projectRevisions.revisionNumber))
|
||||||
|
.limit(input.limit + 1)
|
||||||
|
.all();
|
||||||
|
const hasMore = rows.length > input.limit;
|
||||||
|
const revisions = rows.slice(0, input.limit).map((row) => ({
|
||||||
|
...row,
|
||||||
|
source: row.source as ProjectRevisionSource,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: input.projectId,
|
||||||
|
currentRevision: project.currentRevision,
|
||||||
|
revisions,
|
||||||
|
nextBeforeRevision:
|
||||||
|
hasMore && revisions.length > 0
|
||||||
|
? revisions.at(-1)!.revisionNumber
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
getNextCommand(
|
getNextCommand(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
direction: ProjectHistoryDirection
|
direction: ProjectHistoryDirection
|
||||||
@@ -93,3 +164,22 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateRevisionPageInput(input: ListProjectRevisionsInput) {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(input.limit) ||
|
||||||
|
input.limit < 1 ||
|
||||||
|
input.limit > 100
|
||||||
|
) {
|
||||||
|
throw new Error("Project revision page limit must be between 1 and 100.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.beforeRevision !== undefined &&
|
||||||
|
(!Number.isSafeInteger(input.beforeRevision) ||
|
||||||
|
input.beforeRevision < 1)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project revision cursor must be a positive integer."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { AppDatabase } from "../database-context.js";
|
|||||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
import { projectRevisions } from "../schema/project-revisions.js";
|
import { projectRevisions } from "../schema/project-revisions.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { captureAutomaticProjectSnapshot } from "./automatic-project-snapshot.persistence.js";
|
||||||
|
|
||||||
export function appendProjectRevision(
|
export function appendProjectRevision(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
@@ -73,6 +74,13 @@ export function appendProjectRevision(
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
captureAutomaticProjectSnapshot(database, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
currentRevision: revisionNumber,
|
||||||
|
createdAtIso,
|
||||||
|
actorId: input.actorId,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
revisionId,
|
revisionId,
|
||||||
changeSetId,
|
changeSetId,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertProjectSettingsUpdateProjectCommand,
|
||||||
|
createProjectSettingsUpdateProjectCommand,
|
||||||
|
} from "../../domain/models/project-settings-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
ExecuteProjectSettingsUpdateCommandInput,
|
||||||
|
ProjectSettingsProjectCommandStore,
|
||||||
|
} from "../../domain/ports/project-settings-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
export class ProjectSettingsProjectCommandRepository
|
||||||
|
implements ProjectSettingsProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) {
|
||||||
|
assertProjectSettingsUpdateProjectCommand(input.command);
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const current = tx
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, input.projectId))
|
||||||
|
.get();
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Project not found.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
current.singlePhaseVoltageV ===
|
||||||
|
input.command.payload.singlePhaseVoltageV &&
|
||||||
|
current.threePhaseVoltageV ===
|
||||||
|
input.command.payload.threePhaseVoltageV
|
||||||
|
) {
|
||||||
|
throw new Error("Project settings did not change.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inverse = createProjectSettingsUpdateProjectCommand({
|
||||||
|
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||||
|
});
|
||||||
|
const updated = tx
|
||||||
|
.update(projects)
|
||||||
|
.set(input.command.payload)
|
||||||
|
.where(eq(projects.id, input.projectId))
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error("Project changed before settings update.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
|
||||||
|
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||||
|
import {
|
||||||
|
deserializeProjectStateSnapshot,
|
||||||
|
projectStateSnapshotSchemaVersion,
|
||||||
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
|
import type {
|
||||||
|
CreateNamedProjectSnapshotInput,
|
||||||
|
PreparedProjectSnapshotRestore,
|
||||||
|
ProjectSnapshotMetadata,
|
||||||
|
ProjectSnapshotStore,
|
||||||
|
} from "../../domain/ports/project-snapshot.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { projectSnapshots } from "../schema/project-snapshots.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import {
|
||||||
|
hashProjectStatePayload,
|
||||||
|
readProjectStateSnapshot,
|
||||||
|
} from "./project-state-snapshot.persistence.js";
|
||||||
|
|
||||||
|
export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
createNamed(
|
||||||
|
input: CreateNamedProjectSnapshotInput
|
||||||
|
): ProjectSnapshotMetadata | null {
|
||||||
|
validateCreateInput(input);
|
||||||
|
const snapshotName = input.name.trim();
|
||||||
|
const snapshotDescription =
|
||||||
|
input.description?.trim() || null;
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const current = readProjectStateSnapshot(tx, input.projectId);
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (current.currentRevision !== input.expectedRevision) {
|
||||||
|
throw new ProjectRevisionConflictError(
|
||||||
|
input.projectId,
|
||||||
|
input.expectedRevision,
|
||||||
|
current.currentRevision
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existing = tx
|
||||||
|
.select({ id: projectSnapshots.id })
|
||||||
|
.from(projectSnapshots)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectSnapshots.projectId, input.projectId),
|
||||||
|
eq(projectSnapshots.name, snapshotName)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (existing) {
|
||||||
|
throw new ProjectSnapshotNameConflictError(
|
||||||
|
input.projectId,
|
||||||
|
snapshotName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: ProjectSnapshotMetadata = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
projectId: input.projectId,
|
||||||
|
sourceRevision: current.currentRevision,
|
||||||
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
|
kind: "named",
|
||||||
|
name: snapshotName,
|
||||||
|
description: snapshotDescription,
|
||||||
|
payloadSha256: current.payloadSha256,
|
||||||
|
createdAtIso: new Date().toISOString(),
|
||||||
|
createdByActorId: input.actorId ?? null,
|
||||||
|
};
|
||||||
|
tx.insert(projectSnapshots)
|
||||||
|
.values({
|
||||||
|
...metadata,
|
||||||
|
payloadJson: current.payloadJson,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return metadata;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listByProject(projectId: string): ProjectSnapshotMetadata[] | null {
|
||||||
|
const project = this.database
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.database
|
||||||
|
.select({
|
||||||
|
id: projectSnapshots.id,
|
||||||
|
projectId: projectSnapshots.projectId,
|
||||||
|
sourceRevision: projectSnapshots.sourceRevision,
|
||||||
|
schemaVersion: projectSnapshots.schemaVersion,
|
||||||
|
kind: projectSnapshots.kind,
|
||||||
|
name: projectSnapshots.name,
|
||||||
|
description: projectSnapshots.description,
|
||||||
|
payloadSha256: projectSnapshots.payloadSha256,
|
||||||
|
createdAtIso: projectSnapshots.createdAtIso,
|
||||||
|
createdByActorId: projectSnapshots.createdByActorId,
|
||||||
|
})
|
||||||
|
.from(projectSnapshots)
|
||||||
|
.where(eq(projectSnapshots.projectId, projectId))
|
||||||
|
.orderBy(
|
||||||
|
desc(projectSnapshots.createdAtIso),
|
||||||
|
desc(projectSnapshots.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareRestore(
|
||||||
|
projectId: string,
|
||||||
|
snapshotId: string
|
||||||
|
): PreparedProjectSnapshotRestore | null {
|
||||||
|
const current = readProjectStateSnapshot(this.database, projectId);
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const stored = this.database
|
||||||
|
.select()
|
||||||
|
.from(projectSnapshots)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectSnapshots.id, snapshotId),
|
||||||
|
eq(projectSnapshots.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!stored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const actualChecksum = hashProjectStatePayload(stored.payloadJson);
|
||||||
|
if (actualChecksum !== stored.payloadSha256) {
|
||||||
|
throw new Error("Project snapshot checksum verification failed.");
|
||||||
|
}
|
||||||
|
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||||
|
throw new Error("Project snapshot schema version is not supported.");
|
||||||
|
}
|
||||||
|
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
|
||||||
|
return {
|
||||||
|
snapshot: {
|
||||||
|
id: stored.id,
|
||||||
|
projectId: stored.projectId,
|
||||||
|
sourceRevision: stored.sourceRevision,
|
||||||
|
schemaVersion: stored.schemaVersion,
|
||||||
|
kind: stored.kind,
|
||||||
|
name: stored.name,
|
||||||
|
description: stored.description,
|
||||||
|
payloadSha256: stored.payloadSha256,
|
||||||
|
createdAtIso: stored.createdAtIso,
|
||||||
|
createdByActorId: stored.createdByActorId,
|
||||||
|
},
|
||||||
|
command: createProjectStateRestoreCommand(
|
||||||
|
current.payloadSha256,
|
||||||
|
targetState
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCreateInput(input: CreateNamedProjectSnapshotInput) {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(input.expectedRevision) ||
|
||||||
|
input.expectedRevision < 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Expected project revision must be a non-negative integer."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!input.name.trim()) {
|
||||||
|
throw new Error("Project snapshot name is required.");
|
||||||
|
}
|
||||||
|
if (input.name.trim().length > 100) {
|
||||||
|
throw new Error(
|
||||||
|
"Project snapshot name must not exceed 100 characters."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.description !== undefined &&
|
||||||
|
input.description.trim().length > 500
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project snapshot description must not exceed 500 characters."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
|
||||||
|
import {
|
||||||
|
assertProjectStateRestoreCommand,
|
||||||
|
createProjectStateRestoreCommand,
|
||||||
|
} from "../../domain/models/project-state-restore-command.model.js";
|
||||||
|
import {
|
||||||
|
serializeProjectStateSnapshot,
|
||||||
|
type ProjectStateSnapshot,
|
||||||
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
|
import type {
|
||||||
|
ExecuteProjectStateRestoreCommandInput,
|
||||||
|
ProjectStateRestoreCommandStore,
|
||||||
|
} from "../../domain/ports/project-state-restore-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { consumers } from "../schema/consumers.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { floors } from "../schema/floors.js";
|
||||||
|
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||||
|
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { 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";
|
||||||
|
import {
|
||||||
|
hashProjectStatePayload,
|
||||||
|
readProjectStateSnapshot,
|
||||||
|
} from "./project-state-snapshot.persistence.js";
|
||||||
|
|
||||||
|
export class ProjectStateRestoreCommandRepository
|
||||||
|
implements ProjectStateRestoreCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteProjectStateRestoreCommandInput) {
|
||||||
|
assertProjectStateRestoreCommand(input.command);
|
||||||
|
if (input.command.payload.targetState.project.id !== input.projectId) {
|
||||||
|
throw new Error("Restore state belongs to a different project.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const current = readProjectStateSnapshot(tx, input.projectId);
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Project not found.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
current.payloadSha256 !==
|
||||||
|
input.command.payload.expectedStateSha256
|
||||||
|
) {
|
||||||
|
throw new ProjectStateConflictError(
|
||||||
|
input.projectId,
|
||||||
|
input.command.payload.expectedStateSha256,
|
||||||
|
current.payloadSha256
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPayloadJson = serializeProjectStateSnapshot(
|
||||||
|
input.command.payload.targetState
|
||||||
|
);
|
||||||
|
const inverse = createProjectStateRestoreCommand(
|
||||||
|
hashProjectStatePayload(targetPayloadJson),
|
||||||
|
current.state
|
||||||
|
);
|
||||||
|
|
||||||
|
replaceProjectState(tx, input.command.payload.targetState);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceProjectState(
|
||||||
|
database: AppDatabase,
|
||||||
|
state: ProjectStateSnapshot
|
||||||
|
) {
|
||||||
|
const upgradeOnlyData = captureUpgradeOnlyData(
|
||||||
|
database,
|
||||||
|
state.project.id
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.delete(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
|
database
|
||||||
|
.delete(projectDevices)
|
||||||
|
.where(eq(projectDevices.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
|
database
|
||||||
|
.delete(rooms)
|
||||||
|
.where(eq(rooms.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
|
database
|
||||||
|
.delete(floors)
|
||||||
|
.where(eq(floors.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
database
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
name: state.project.name,
|
||||||
|
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: state.project.threePhaseVoltageV,
|
||||||
|
})
|
||||||
|
.where(eq(projects.id, state.project.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
insertMany(database, floors, state.floors);
|
||||||
|
insertMany(database, rooms, state.rooms);
|
||||||
|
insertMany(database, projectDevices, state.projectDevices);
|
||||||
|
insertMany(database, distributionBoards, state.distributionBoards);
|
||||||
|
insertMany(database, circuitLists, state.circuitLists);
|
||||||
|
insertMany(database, circuitSections, state.circuitSections);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
circuits,
|
||||||
|
state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({
|
||||||
|
...circuit,
|
||||||
|
isReserve: circuit.isReserve ? 1 : 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
circuitDeviceRows,
|
||||||
|
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||||
|
);
|
||||||
|
restoreCompatibleUpgradeOnlyData(database, state, upgradeOnlyData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureUpgradeOnlyData(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string
|
||||||
|
) {
|
||||||
|
const consumerLinks = database
|
||||||
|
.select({
|
||||||
|
id: consumers.id,
|
||||||
|
distributionBoardId: consumers.distributionBoardId,
|
||||||
|
circuitListId: consumers.circuitListId,
|
||||||
|
projectDeviceId: consumers.projectDeviceId,
|
||||||
|
roomId: consumers.roomId,
|
||||||
|
})
|
||||||
|
.from(consumers)
|
||||||
|
.where(eq(consumers.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
const migrationMappings = database
|
||||||
|
.select({
|
||||||
|
consumerId: legacyConsumerCircuitMigrations.consumerId,
|
||||||
|
circuitId: legacyConsumerCircuitMigrations.circuitId,
|
||||||
|
circuitDeviceRowId:
|
||||||
|
legacyConsumerCircuitMigrations.circuitDeviceRowId,
|
||||||
|
circuitListId: legacyConsumerCircuitMigrations.circuitListId,
|
||||||
|
createdAtIso: legacyConsumerCircuitMigrations.createdAtIso,
|
||||||
|
})
|
||||||
|
.from(legacyConsumerCircuitMigrations)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(
|
||||||
|
circuitLists.id,
|
||||||
|
legacyConsumerCircuitMigrations.circuitListId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
const migrationReports = database
|
||||||
|
.select({
|
||||||
|
id: legacyConsumerMigrationReports.id,
|
||||||
|
circuitListId: legacyConsumerMigrationReports.circuitListId,
|
||||||
|
legacyConsumerCount:
|
||||||
|
legacyConsumerMigrationReports.legacyConsumerCount,
|
||||||
|
createdCircuitCount:
|
||||||
|
legacyConsumerMigrationReports.createdCircuitCount,
|
||||||
|
createdDeviceRowCount:
|
||||||
|
legacyConsumerMigrationReports.createdDeviceRowCount,
|
||||||
|
duplicateGroupedCount:
|
||||||
|
legacyConsumerMigrationReports.duplicateGroupedCount,
|
||||||
|
generatedIdentifierCount:
|
||||||
|
legacyConsumerMigrationReports.generatedIdentifierCount,
|
||||||
|
unassignedRowCount:
|
||||||
|
legacyConsumerMigrationReports.unassignedRowCount,
|
||||||
|
warningsJson: legacyConsumerMigrationReports.warningsJson,
|
||||||
|
generatedIdentifiersJson:
|
||||||
|
legacyConsumerMigrationReports.generatedIdentifiersJson,
|
||||||
|
duplicateGroupsJson:
|
||||||
|
legacyConsumerMigrationReports.duplicateGroupsJson,
|
||||||
|
createdAtIso: legacyConsumerMigrationReports.createdAtIso,
|
||||||
|
})
|
||||||
|
.from(legacyConsumerMigrationReports)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, legacyConsumerMigrationReports.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
return { consumerLinks, migrationMappings, migrationReports };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCompatibleUpgradeOnlyData(
|
||||||
|
database: AppDatabase,
|
||||||
|
state: ProjectStateSnapshot,
|
||||||
|
data: ReturnType<typeof captureUpgradeOnlyData>
|
||||||
|
) {
|
||||||
|
const boardIds = new Set(
|
||||||
|
state.distributionBoards.map((entry) => entry.id)
|
||||||
|
);
|
||||||
|
const listIds = new Set(state.circuitLists.map((entry) => entry.id));
|
||||||
|
const deviceIds = new Set(
|
||||||
|
state.projectDevices.map((entry) => entry.id)
|
||||||
|
);
|
||||||
|
const roomIds = new Set(state.rooms.map((entry) => entry.id));
|
||||||
|
const circuitIds = new Set(state.circuits.map((entry) => entry.id));
|
||||||
|
const rowIds = new Set(
|
||||||
|
state.circuits.flatMap((circuit) =>
|
||||||
|
circuit.deviceRows.map((entry) => entry.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const link of data.consumerLinks) {
|
||||||
|
database
|
||||||
|
.update(consumers)
|
||||||
|
.set({
|
||||||
|
distributionBoardId:
|
||||||
|
link.distributionBoardId &&
|
||||||
|
boardIds.has(link.distributionBoardId)
|
||||||
|
? link.distributionBoardId
|
||||||
|
: null,
|
||||||
|
circuitListId:
|
||||||
|
link.circuitListId && listIds.has(link.circuitListId)
|
||||||
|
? link.circuitListId
|
||||||
|
: null,
|
||||||
|
projectDeviceId:
|
||||||
|
link.projectDeviceId && deviceIds.has(link.projectDeviceId)
|
||||||
|
? link.projectDeviceId
|
||||||
|
: null,
|
||||||
|
roomId:
|
||||||
|
link.roomId && roomIds.has(link.roomId) ? link.roomId : null,
|
||||||
|
})
|
||||||
|
.where(eq(consumers.id, link.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
legacyConsumerCircuitMigrations,
|
||||||
|
data.migrationMappings.filter(
|
||||||
|
(entry) =>
|
||||||
|
listIds.has(entry.circuitListId) &&
|
||||||
|
circuitIds.has(entry.circuitId) &&
|
||||||
|
rowIds.has(entry.circuitDeviceRowId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
legacyConsumerMigrationReports,
|
||||||
|
data.migrationReports.filter((entry) =>
|
||||||
|
listIds.has(entry.circuitListId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
|
database: AppDatabase,
|
||||||
|
table: TTable,
|
||||||
|
values: unknown[]
|
||||||
|
) {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.insert(table)
|
||||||
|
.values(values as never)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
parseProjectStateSnapshot,
|
||||||
|
projectStateSnapshotSchemaVersion,
|
||||||
|
serializeProjectStateSnapshot,
|
||||||
|
type ProjectStateSnapshot,
|
||||||
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { floors } from "../schema/floors.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
|
||||||
|
export interface PersistedProjectStateSnapshot {
|
||||||
|
currentRevision: number;
|
||||||
|
state: ProjectStateSnapshot;
|
||||||
|
payloadJson: string;
|
||||||
|
payloadSha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readProjectStateSnapshot(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string
|
||||||
|
): PersistedProjectStateSnapshot | null {
|
||||||
|
const project = database
|
||||||
|
.select({
|
||||||
|
id: projects.id,
|
||||||
|
name: projects.name,
|
||||||
|
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||||
|
currentRevision: projects.currentRevision,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardRows = database
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, projectId))
|
||||||
|
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
||||||
|
.all();
|
||||||
|
const listRows = database
|
||||||
|
.select()
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.orderBy(asc(circuitLists.name), asc(circuitLists.id))
|
||||||
|
.all();
|
||||||
|
const sectionRows = database
|
||||||
|
.select({
|
||||||
|
id: circuitSections.id,
|
||||||
|
circuitListId: circuitSections.circuitListId,
|
||||||
|
key: circuitSections.key,
|
||||||
|
displayName: circuitSections.displayName,
|
||||||
|
prefix: circuitSections.prefix,
|
||||||
|
sortOrder: circuitSections.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuitSections)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuitSections.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.orderBy(
|
||||||
|
asc(circuitSections.circuitListId),
|
||||||
|
asc(circuitSections.sortOrder),
|
||||||
|
asc(circuitSections.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const circuitRows = database
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
circuitListId: circuits.circuitListId,
|
||||||
|
sectionId: circuits.sectionId,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
displayName: circuits.displayName,
|
||||||
|
sortOrder: circuits.sortOrder,
|
||||||
|
protectionType: circuits.protectionType,
|
||||||
|
protectionRatedCurrent: circuits.protectionRatedCurrent,
|
||||||
|
protectionCharacteristic: circuits.protectionCharacteristic,
|
||||||
|
cableType: circuits.cableType,
|
||||||
|
cableCrossSection: circuits.cableCrossSection,
|
||||||
|
cableLength: circuits.cableLength,
|
||||||
|
rcdAssignment: circuits.rcdAssignment,
|
||||||
|
terminalDesignation: circuits.terminalDesignation,
|
||||||
|
voltage: circuits.voltage,
|
||||||
|
controlRequirement: circuits.controlRequirement,
|
||||||
|
status: circuits.status,
|
||||||
|
isReserve: circuits.isReserve,
|
||||||
|
remark: circuits.remark,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.orderBy(
|
||||||
|
asc(circuits.circuitListId),
|
||||||
|
asc(circuits.sortOrder),
|
||||||
|
asc(circuits.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const deviceRowRows = database
|
||||||
|
.select({
|
||||||
|
id: circuitDeviceRows.id,
|
||||||
|
circuitId: circuitDeviceRows.circuitId,
|
||||||
|
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
|
||||||
|
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
|
||||||
|
sortOrder: circuitDeviceRows.sortOrder,
|
||||||
|
name: circuitDeviceRows.name,
|
||||||
|
displayName: circuitDeviceRows.displayName,
|
||||||
|
phaseType: circuitDeviceRows.phaseType,
|
||||||
|
connectionKind: circuitDeviceRows.connectionKind,
|
||||||
|
costGroup: circuitDeviceRows.costGroup,
|
||||||
|
category: circuitDeviceRows.category,
|
||||||
|
level: circuitDeviceRows.level,
|
||||||
|
roomId: circuitDeviceRows.roomId,
|
||||||
|
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
|
||||||
|
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
|
||||||
|
quantity: circuitDeviceRows.quantity,
|
||||||
|
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
||||||
|
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
||||||
|
cosPhi: circuitDeviceRows.cosPhi,
|
||||||
|
remark: circuitDeviceRows.remark,
|
||||||
|
overriddenFields: circuitDeviceRows.overriddenFields,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
|
||||||
|
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.orderBy(
|
||||||
|
asc(circuitDeviceRows.circuitId),
|
||||||
|
asc(circuitDeviceRows.sortOrder),
|
||||||
|
asc(circuitDeviceRows.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const projectDeviceRows = database
|
||||||
|
.select()
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(eq(projectDevices.projectId, projectId))
|
||||||
|
.orderBy(asc(projectDevices.displayName), asc(projectDevices.id))
|
||||||
|
.all();
|
||||||
|
const floorRows = database
|
||||||
|
.select()
|
||||||
|
.from(floors)
|
||||||
|
.where(eq(floors.projectId, projectId))
|
||||||
|
.orderBy(asc(floors.sortOrder), asc(floors.id))
|
||||||
|
.all();
|
||||||
|
const roomRows = database
|
||||||
|
.select()
|
||||||
|
.from(rooms)
|
||||||
|
.where(eq(rooms.projectId, projectId))
|
||||||
|
.orderBy(asc(rooms.roomNumber), asc(rooms.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const deviceRowsByCircuit = new Map<string, typeof deviceRowRows>();
|
||||||
|
for (const row of deviceRowRows) {
|
||||||
|
const entries = deviceRowsByCircuit.get(row.circuitId) ?? [];
|
||||||
|
entries.push(row);
|
||||||
|
deviceRowsByCircuit.set(row.circuitId, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = parseProjectStateSnapshot({
|
||||||
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||||
|
},
|
||||||
|
distributionBoards: boardRows,
|
||||||
|
circuitLists: listRows,
|
||||||
|
circuitSections: sectionRows,
|
||||||
|
circuits: circuitRows.map((circuit) => ({
|
||||||
|
...circuit,
|
||||||
|
isReserve: Boolean(circuit.isReserve),
|
||||||
|
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
|
||||||
|
})),
|
||||||
|
projectDevices: projectDeviceRows,
|
||||||
|
floors: floorRows,
|
||||||
|
rooms: roomRows,
|
||||||
|
});
|
||||||
|
const payloadJson = serializeProjectStateSnapshot(state);
|
||||||
|
return {
|
||||||
|
currentRevision: project.currentRevision,
|
||||||
|
state,
|
||||||
|
payloadJson,
|
||||||
|
payloadSha256: hashProjectStatePayload(payloadJson),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashProjectStatePayload(payloadJson: string) {
|
||||||
|
return crypto.createHash("sha256").update(payloadJson).digest("hex");
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { db } from "../client.js";
|
|||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import type {
|
import type {
|
||||||
CreateProjectInput,
|
CreateProjectInput,
|
||||||
UpdateProjectSettingsInput,
|
|
||||||
} from "../../shared/validation/project-structure.schemas.js";
|
} from "../../shared/validation/project-structure.schemas.js";
|
||||||
|
|
||||||
export class ProjectRepository {
|
export class ProjectRepository {
|
||||||
@@ -30,16 +29,6 @@ export class ProjectRepository {
|
|||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSettings(projectId: string, input: UpdateProjectSettingsInput) {
|
|
||||||
await db
|
|
||||||
.update(projects)
|
|
||||||
.set({
|
|
||||||
singlePhaseVoltageV: input.singlePhaseVoltageV,
|
|
||||||
threePhaseVoltageV: input.threePhaseVoltageV,
|
|
||||||
})
|
|
||||||
.where(eq(projects.id, projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(projectId: string) {
|
async delete(projectId: string) {
|
||||||
await db.delete(projects).where(eq(projects.id, projectId));
|
await db.delete(projects).where(eq(projects.id, projectId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
sqliteTable,
|
||||||
|
text,
|
||||||
|
unique,
|
||||||
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
import { projectSnapshotKinds } from "../../domain/models/project-snapshot-policy.model.js";
|
||||||
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
export const projectSnapshots = sqliteTable(
|
||||||
|
"project_snapshots",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
sourceRevision: integer("source_revision").notNull(),
|
||||||
|
schemaVersion: integer("schema_version").notNull(),
|
||||||
|
kind: text("kind", { enum: projectSnapshotKinds })
|
||||||
|
.notNull()
|
||||||
|
.default("named"),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
payloadJson: text("payload_json").notNull(),
|
||||||
|
payloadSha256: text("payload_sha256").notNull(),
|
||||||
|
createdAtIso: text("created_at_iso").notNull(),
|
||||||
|
createdByActorId: text("created_by_actor_id"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
unique("project_snapshots_project_name_unique").on(
|
||||||
|
table.projectId,
|
||||||
|
table.name
|
||||||
|
),
|
||||||
|
index("project_snapshots_project_created_idx").on(
|
||||||
|
table.projectId,
|
||||||
|
table.createdAtIso
|
||||||
|
),
|
||||||
|
index("project_snapshots_project_kind_revision_idx").on(
|
||||||
|
table.projectId,
|
||||||
|
table.kind,
|
||||||
|
table.sourceRevision
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export class ProjectSnapshotNameConflictError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly projectId: string,
|
||||||
|
readonly snapshotName: string
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
`Project ${projectId} already has a snapshot named "${snapshotName}".`
|
||||||
|
);
|
||||||
|
this.name = "ProjectSnapshotNameConflictError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export class ProjectStateConflictError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly projectId: string,
|
||||||
|
public readonly expectedSha256: string,
|
||||||
|
public readonly actualSha256: string
|
||||||
|
) {
|
||||||
|
super(`Project ${projectId} changed outside the prepared restore state.`);
|
||||||
|
this.name = "ProjectStateConflictError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
assertCircuitSectionReorderProjectCommand,
|
||||||
|
circuitSectionReorderCommandSchemaVersion,
|
||||||
|
type CircuitSectionReorderAssignment,
|
||||||
|
} from "./circuit-section-reorder-project-command.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitSectionsReorderCommandType =
|
||||||
|
"circuit.reorder-sections" as const;
|
||||||
|
export const circuitSectionsReorderCommandSchemaVersion =
|
||||||
|
circuitSectionReorderCommandSchemaVersion;
|
||||||
|
|
||||||
|
export interface CircuitSectionsReorderSection {
|
||||||
|
sectionId: string;
|
||||||
|
assignments: CircuitSectionReorderAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitSectionsReorderCommandPayload {
|
||||||
|
sections: CircuitSectionsReorderSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitSectionsReorderProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitSectionsReorderCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitSectionsReorderCommandSchemaVersion;
|
||||||
|
type: typeof circuitSectionsReorderCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitSectionsReorderProjectCommand(
|
||||||
|
sections: CircuitSectionsReorderSection[]
|
||||||
|
): CircuitSectionsReorderProjectCommand {
|
||||||
|
const command: CircuitSectionsReorderProjectCommand = {
|
||||||
|
schemaVersion: circuitSectionsReorderCommandSchemaVersion,
|
||||||
|
type: circuitSectionsReorderCommandType,
|
||||||
|
payload: { sections },
|
||||||
|
};
|
||||||
|
assertCircuitSectionsReorderProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitSectionsReorderProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitSectionsReorderProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !==
|
||||||
|
circuitSectionsReorderCommandSchemaVersion ||
|
||||||
|
command.type !== circuitSectionsReorderCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit sections reorder command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit sections reorder payload must be an object."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sections = command.payload.sections;
|
||||||
|
if (!Array.isArray(sections) || sections.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit sections reorder command requires sections."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionIds = new Set<string>();
|
||||||
|
for (const section of sections) {
|
||||||
|
if (!isPlainObject(section)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit sections reorder command contains an invalid section."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof section.sectionId !== "string" ||
|
||||||
|
!section.sectionId.trim()
|
||||||
|
) {
|
||||||
|
throw new Error("sectionId must be a non-empty string.");
|
||||||
|
}
|
||||||
|
if (sectionIds.has(section.sectionId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit sections reorder command contains duplicate section ids."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertCircuitSectionReorderProjectCommand({
|
||||||
|
schemaVersion: circuitSectionReorderCommandSchemaVersion,
|
||||||
|
type: "circuit.reorder-section",
|
||||||
|
payload: {
|
||||||
|
sectionId: section.sectionId,
|
||||||
|
assignments: section.assignments,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sectionIds.add(section.sectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const distributionBoardInsertCommandType =
|
||||||
|
"distribution-board.insert" as const;
|
||||||
|
export const distributionBoardDeleteCommandType =
|
||||||
|
"distribution-board.delete" as const;
|
||||||
|
export const distributionBoardStructureCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export const defaultCircuitSectionDefinitions = [
|
||||||
|
{
|
||||||
|
key: "lighting",
|
||||||
|
displayName: "Lighting",
|
||||||
|
prefix: "-1F",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "single_phase",
|
||||||
|
displayName: "Single-phase circuits",
|
||||||
|
prefix: "-2F",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "three_phase",
|
||||||
|
displayName: "Three-phase circuits",
|
||||||
|
prefix: "-3F",
|
||||||
|
sortOrder: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "unassigned",
|
||||||
|
displayName: "Unassigned",
|
||||||
|
prefix: "-UF",
|
||||||
|
sortOrder: 90,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface DistributionBoardStructureSnapshot {
|
||||||
|
distributionBoard: {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
circuitList: {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
distributionBoardId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
sections: Array<{
|
||||||
|
id: string;
|
||||||
|
circuitListId: string;
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
prefix: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DistributionBoardStructureCommandPayload {
|
||||||
|
structure: DistributionBoardStructureSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionBoardInsertProjectCommand
|
||||||
|
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
||||||
|
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
||||||
|
type: typeof distributionBoardInsertCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionBoardDeleteProjectCommand
|
||||||
|
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
||||||
|
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
||||||
|
type: typeof distributionBoardDeleteCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DistributionBoardStructureProjectCommand =
|
||||||
|
| DistributionBoardInsertProjectCommand
|
||||||
|
| DistributionBoardDeleteProjectCommand;
|
||||||
|
|
||||||
|
export function createDistributionBoardStructureSnapshot(
|
||||||
|
projectId: string,
|
||||||
|
name: string
|
||||||
|
): DistributionBoardStructureSnapshot {
|
||||||
|
const normalizedName = name.trim();
|
||||||
|
assertNonEmptyString(projectId, "projectId");
|
||||||
|
assertNonEmptyString(normalizedName, "name");
|
||||||
|
const structureId = crypto.randomUUID();
|
||||||
|
const structure: DistributionBoardStructureSnapshot = {
|
||||||
|
distributionBoard: {
|
||||||
|
id: structureId,
|
||||||
|
projectId,
|
||||||
|
name: normalizedName,
|
||||||
|
},
|
||||||
|
circuitList: {
|
||||||
|
id: structureId,
|
||||||
|
projectId,
|
||||||
|
distributionBoardId: structureId,
|
||||||
|
name: `${normalizedName} Stromkreisliste`,
|
||||||
|
},
|
||||||
|
sections: defaultCircuitSectionDefinitions.map((section) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
circuitListId: structureId,
|
||||||
|
...section,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
assertDistributionBoardStructureSnapshot(structure);
|
||||||
|
return structure;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDistributionBoardInsertProjectCommand(
|
||||||
|
structure: DistributionBoardStructureSnapshot
|
||||||
|
): DistributionBoardInsertProjectCommand {
|
||||||
|
const command: DistributionBoardInsertProjectCommand = {
|
||||||
|
schemaVersion: distributionBoardStructureCommandSchemaVersion,
|
||||||
|
type: distributionBoardInsertCommandType,
|
||||||
|
payload: { structure },
|
||||||
|
};
|
||||||
|
assertDistributionBoardInsertProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDistributionBoardDeleteProjectCommand(
|
||||||
|
structure: DistributionBoardStructureSnapshot
|
||||||
|
): DistributionBoardDeleteProjectCommand {
|
||||||
|
const command: DistributionBoardDeleteProjectCommand = {
|
||||||
|
schemaVersion: distributionBoardStructureCommandSchemaVersion,
|
||||||
|
type: distributionBoardDeleteCommandType,
|
||||||
|
payload: { structure },
|
||||||
|
};
|
||||||
|
assertDistributionBoardDeleteProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertDistributionBoardInsertProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is DistributionBoardInsertProjectCommand {
|
||||||
|
assertDistributionBoardStructureProjectCommand(
|
||||||
|
command,
|
||||||
|
distributionBoardInsertCommandType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertDistributionBoardDeleteProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is DistributionBoardDeleteProjectCommand {
|
||||||
|
assertDistributionBoardStructureProjectCommand(
|
||||||
|
command,
|
||||||
|
distributionBoardDeleteCommandType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertDistributionBoardStructureSnapshot(
|
||||||
|
structure: unknown
|
||||||
|
): asserts structure is DistributionBoardStructureSnapshot {
|
||||||
|
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
|
||||||
|
throw new Error("Distribution-board structure is invalid.");
|
||||||
|
}
|
||||||
|
const { distributionBoard, circuitList, sections } = structure;
|
||||||
|
if (
|
||||||
|
!isPlainObject(distributionBoard) ||
|
||||||
|
Object.keys(distributionBoard).length !== 3 ||
|
||||||
|
!isPlainObject(circuitList) ||
|
||||||
|
Object.keys(circuitList).length !== 4 ||
|
||||||
|
!Array.isArray(sections) ||
|
||||||
|
sections.length !== defaultCircuitSectionDefinitions.length
|
||||||
|
) {
|
||||||
|
throw new Error("Distribution-board structure is incomplete.");
|
||||||
|
}
|
||||||
|
for (const [field, value] of Object.entries(distributionBoard)) {
|
||||||
|
assertNonEmptyString(value, `distributionBoard.${field}`);
|
||||||
|
}
|
||||||
|
for (const [field, value] of Object.entries(circuitList)) {
|
||||||
|
assertNonEmptyString(value, `circuitList.${field}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
circuitList.projectId !== distributionBoard.projectId ||
|
||||||
|
circuitList.distributionBoardId !== distributionBoard.id ||
|
||||||
|
circuitList.name !==
|
||||||
|
`${distributionBoard.name} Stromkreisliste`
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Distribution board and circuit list are inconsistent."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionIds = new Set<string>();
|
||||||
|
for (let index = 0; index < sections.length; index += 1) {
|
||||||
|
const section = sections[index];
|
||||||
|
const expected = defaultCircuitSectionDefinitions[index];
|
||||||
|
if (
|
||||||
|
!isPlainObject(section) ||
|
||||||
|
Object.keys(section).length !== 6
|
||||||
|
) {
|
||||||
|
throw new Error("Default circuit section is invalid.");
|
||||||
|
}
|
||||||
|
assertNonEmptyString(section.id, "section.id");
|
||||||
|
if (sectionIds.has(section.id)) {
|
||||||
|
throw new Error("Default circuit-section ids must be unique.");
|
||||||
|
}
|
||||||
|
sectionIds.add(section.id);
|
||||||
|
if (
|
||||||
|
section.circuitListId !== circuitList.id ||
|
||||||
|
section.key !== expected.key ||
|
||||||
|
section.displayName !== expected.displayName ||
|
||||||
|
section.prefix !== expected.prefix ||
|
||||||
|
section.sortOrder !== expected.sortOrder
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Distribution-board structure has invalid default sections."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDistributionBoardStructureProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>,
|
||||||
|
expectedType:
|
||||||
|
| typeof distributionBoardInsertCommandType
|
||||||
|
| typeof distributionBoardDeleteCommandType
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !==
|
||||||
|
distributionBoardStructureCommandSchemaVersion ||
|
||||||
|
command.type !== expectedType ||
|
||||||
|
!isPlainObject(command.payload) ||
|
||||||
|
Object.keys(command.payload).length !== 1
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported distribution-board structure command.");
|
||||||
|
}
|
||||||
|
assertDistributionBoardStructureSnapshot(command.payload.structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(
|
||||||
|
value: unknown,
|
||||||
|
field: string
|
||||||
|
): asserts value is string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const projectSettingsUpdateCommandType =
|
||||||
|
"project.update-settings" as const;
|
||||||
|
export const projectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ProjectSettingsValues {
|
||||||
|
singlePhaseVoltageV: number;
|
||||||
|
threePhaseVoltageV: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSettingsUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<ProjectSettingsValues> {
|
||||||
|
schemaVersion: typeof projectSettingsUpdateCommandSchemaVersion;
|
||||||
|
type: typeof projectSettingsUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProjectSettingsUpdateProjectCommand(
|
||||||
|
values: ProjectSettingsValues
|
||||||
|
): ProjectSettingsUpdateProjectCommand {
|
||||||
|
const command: ProjectSettingsUpdateProjectCommand = {
|
||||||
|
schemaVersion: projectSettingsUpdateCommandSchemaVersion,
|
||||||
|
type: projectSettingsUpdateCommandType,
|
||||||
|
payload: { ...values },
|
||||||
|
};
|
||||||
|
assertProjectSettingsUpdateProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertProjectSettingsUpdateProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is ProjectSettingsUpdateProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion ||
|
||||||
|
command.type !== projectSettingsUpdateCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported project settings update command.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isPlainObject(command.payload) ||
|
||||||
|
!isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) ||
|
||||||
|
!isPositiveFiniteNumber(command.payload.threePhaseVoltageV) ||
|
||||||
|
Object.keys(command.payload).length !== 2
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project settings update command contains invalid voltages."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPositiveFiniteNumber(value: unknown): value is number {
|
||||||
|
return (
|
||||||
|
typeof value === "number" &&
|
||||||
|
Number.isFinite(value) &&
|
||||||
|
value > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const projectSnapshotKinds = ["named", "automatic"] as const;
|
||||||
|
export type ProjectSnapshotKind =
|
||||||
|
(typeof projectSnapshotKinds)[number];
|
||||||
|
|
||||||
|
export const automaticProjectSnapshotIntervalRevisions = 25;
|
||||||
|
export const automaticProjectSnapshotRetentionCount = 12;
|
||||||
|
|
||||||
|
export function shouldCaptureAutomaticProjectSnapshot(
|
||||||
|
currentRevision: number,
|
||||||
|
latestAutomaticRevision: number | null
|
||||||
|
) {
|
||||||
|
assertRevision(currentRevision, "Current");
|
||||||
|
if (latestAutomaticRevision !== null) {
|
||||||
|
assertRevision(latestAutomaticRevision, "Latest automatic");
|
||||||
|
if (latestAutomaticRevision > currentRevision) {
|
||||||
|
throw new Error(
|
||||||
|
"Latest automatic snapshot revision exceeds current revision."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentRevision < automaticProjectSnapshotIntervalRevisions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
latestAutomaticRevision === null ||
|
||||||
|
currentRevision - latestAutomaticRevision >=
|
||||||
|
automaticProjectSnapshotIntervalRevisions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRevision(value: number, label: string) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${label} snapshot revision must be a non-negative integer.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
parseProjectStateSnapshot,
|
||||||
|
type ProjectStateSnapshot,
|
||||||
|
} from "./project-state-snapshot.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const projectStateRestoreCommandType = "project.restore-state";
|
||||||
|
export const projectStateRestoreCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ProjectStateRestoreCommandPayload {
|
||||||
|
expectedStateSha256: string;
|
||||||
|
targetState: ProjectStateSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectStateRestoreCommand = SerializedProjectCommand<
|
||||||
|
ProjectStateRestoreCommandPayload
|
||||||
|
> & {
|
||||||
|
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
|
||||||
|
type: typeof projectStateRestoreCommandType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createProjectStateRestoreCommand(
|
||||||
|
expectedStateSha256: string,
|
||||||
|
targetState: ProjectStateSnapshot
|
||||||
|
): ProjectStateRestoreCommand {
|
||||||
|
const command: ProjectStateRestoreCommand = {
|
||||||
|
schemaVersion: projectStateRestoreCommandSchemaVersion,
|
||||||
|
type: projectStateRestoreCommandType,
|
||||||
|
payload: {
|
||||||
|
expectedStateSha256,
|
||||||
|
targetState: parseProjectStateSnapshot(targetState),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assertProjectStateRestoreCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertProjectStateRestoreCommand(
|
||||||
|
value: unknown
|
||||||
|
): asserts value is ProjectStateRestoreCommand {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
throw new Error("Project state restore command must be an object.");
|
||||||
|
}
|
||||||
|
const command = value as Partial<ProjectStateRestoreCommand>;
|
||||||
|
if (
|
||||||
|
command.type !== projectStateRestoreCommandType ||
|
||||||
|
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported project state restore command.");
|
||||||
|
}
|
||||||
|
if (!command.payload || typeof command.payload !== "object") {
|
||||||
|
throw new Error("Project state restore payload is required.");
|
||||||
|
}
|
||||||
|
const payload = command.payload as Partial<ProjectStateRestoreCommandPayload>;
|
||||||
|
if (
|
||||||
|
typeof payload.expectedStateSha256 !== "string" ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(payload.expectedStateSha256)
|
||||||
|
) {
|
||||||
|
throw new Error("Expected project state checksum is invalid.");
|
||||||
|
}
|
||||||
|
parseProjectStateSnapshot(payload.targetState);
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const projectStateSnapshotSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
const idSchema = z.string().trim().min(1);
|
||||||
|
const nullableStringSchema = z.string().nullable();
|
||||||
|
const finiteNumberSchema = z.number().finite();
|
||||||
|
|
||||||
|
const projectSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
singlePhaseVoltageV: finiteNumberSchema.positive(),
|
||||||
|
threePhaseVoltageV: finiteNumberSchema.positive(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const distributionBoardSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const circuitListSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
distributionBoardId: idSchema,
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const circuitSectionSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
circuitListId: idSchema,
|
||||||
|
key: z.string().trim().min(1),
|
||||||
|
displayName: z.string().trim().min(1),
|
||||||
|
prefix: z.string().trim().min(1),
|
||||||
|
sortOrder: finiteNumberSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const circuitDeviceRowSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
circuitId: idSchema,
|
||||||
|
linkedProjectDeviceId: idSchema.nullable(),
|
||||||
|
legacyConsumerId: z.string().nullable(),
|
||||||
|
sortOrder: finiteNumberSchema,
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
displayName: z.string().trim().min(1),
|
||||||
|
phaseType: nullableStringSchema,
|
||||||
|
connectionKind: nullableStringSchema,
|
||||||
|
costGroup: nullableStringSchema,
|
||||||
|
category: nullableStringSchema,
|
||||||
|
level: nullableStringSchema,
|
||||||
|
roomId: idSchema.nullable(),
|
||||||
|
roomNumberSnapshot: nullableStringSchema,
|
||||||
|
roomNameSnapshot: nullableStringSchema,
|
||||||
|
quantity: finiteNumberSchema.nonnegative(),
|
||||||
|
powerPerUnit: finiteNumberSchema.nonnegative(),
|
||||||
|
simultaneityFactor: finiteNumberSchema.nonnegative(),
|
||||||
|
cosPhi: finiteNumberSchema.positive().nullable(),
|
||||||
|
remark: nullableStringSchema,
|
||||||
|
overriddenFields: nullableStringSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const circuitSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
circuitListId: idSchema,
|
||||||
|
sectionId: idSchema,
|
||||||
|
equipmentIdentifier: z.string().trim().min(1),
|
||||||
|
displayName: nullableStringSchema,
|
||||||
|
sortOrder: finiteNumberSchema,
|
||||||
|
protectionType: nullableStringSchema,
|
||||||
|
protectionRatedCurrent: finiteNumberSchema.nonnegative().nullable(),
|
||||||
|
protectionCharacteristic: nullableStringSchema,
|
||||||
|
cableType: nullableStringSchema,
|
||||||
|
cableCrossSection: nullableStringSchema,
|
||||||
|
cableLength: finiteNumberSchema.nonnegative().nullable(),
|
||||||
|
rcdAssignment: nullableStringSchema,
|
||||||
|
terminalDesignation: nullableStringSchema,
|
||||||
|
voltage: finiteNumberSchema.positive().nullable(),
|
||||||
|
controlRequirement: nullableStringSchema,
|
||||||
|
status: nullableStringSchema,
|
||||||
|
isReserve: z.boolean(),
|
||||||
|
remark: nullableStringSchema,
|
||||||
|
deviceRows: z.array(circuitDeviceRowSchema),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const projectDeviceSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
displayName: z.string().trim().min(1),
|
||||||
|
phaseType: z.enum(["single_phase", "three_phase"]),
|
||||||
|
connectionKind: nullableStringSchema,
|
||||||
|
costGroup: nullableStringSchema,
|
||||||
|
category: nullableStringSchema,
|
||||||
|
quantity: finiteNumberSchema.nonnegative(),
|
||||||
|
powerPerUnit: finiteNumberSchema.nonnegative(),
|
||||||
|
simultaneityFactor: finiteNumberSchema.min(0).max(1),
|
||||||
|
cosPhi: finiteNumberSchema.min(0).max(1).nullable(),
|
||||||
|
remark: nullableStringSchema,
|
||||||
|
voltageV: finiteNumberSchema.positive().nullable(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const floorSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
sortOrder: finiteNumberSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const roomSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
floorId: idSchema.nullable(),
|
||||||
|
roomNumber: z.string().trim().min(1),
|
||||||
|
roomName: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const projectStateSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||||
|
project: projectSchema,
|
||||||
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
|
circuitLists: z.array(circuitListSchema),
|
||||||
|
circuitSections: z.array(circuitSectionSchema),
|
||||||
|
circuits: z.array(circuitSchema),
|
||||||
|
projectDevices: z.array(projectDeviceSchema),
|
||||||
|
floors: z.array(floorSchema),
|
||||||
|
rooms: z.array(roomSchema),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ProjectStateSnapshot = z.infer<
|
||||||
|
typeof projectStateSnapshotSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function parseProjectStateSnapshot(
|
||||||
|
value: unknown
|
||||||
|
): ProjectStateSnapshot {
|
||||||
|
const snapshot = projectStateSnapshotSchema.parse(value);
|
||||||
|
assertProjectStateSnapshotRelations(snapshot);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeProjectStateSnapshot(
|
||||||
|
snapshot: ProjectStateSnapshot
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(parseProjectStateSnapshot(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeProjectStateSnapshot(
|
||||||
|
serialized: string
|
||||||
|
): ProjectStateSnapshot {
|
||||||
|
return parseProjectStateSnapshot(JSON.parse(serialized) as unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertProjectStateSnapshotRelations(
|
||||||
|
snapshot: ProjectStateSnapshot
|
||||||
|
) {
|
||||||
|
const projectId = snapshot.project.id;
|
||||||
|
const boardIds = uniqueIds(
|
||||||
|
snapshot.distributionBoards,
|
||||||
|
"distribution board"
|
||||||
|
);
|
||||||
|
const circuitListIds = uniqueIds(
|
||||||
|
snapshot.circuitLists,
|
||||||
|
"circuit list"
|
||||||
|
);
|
||||||
|
const sectionIds = uniqueIds(
|
||||||
|
snapshot.circuitSections,
|
||||||
|
"circuit section"
|
||||||
|
);
|
||||||
|
const projectDeviceIds = uniqueIds(
|
||||||
|
snapshot.projectDevices,
|
||||||
|
"project device"
|
||||||
|
);
|
||||||
|
const floorIds = uniqueIds(snapshot.floors, "floor");
|
||||||
|
const roomIds = uniqueIds(snapshot.rooms, "room");
|
||||||
|
uniqueIds(snapshot.circuits, "circuit");
|
||||||
|
const sectionById = new Map(
|
||||||
|
snapshot.circuitSections.map((entry) => [entry.id, entry])
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const board of snapshot.distributionBoards) {
|
||||||
|
assertProjectOwnership(board.projectId, projectId, "distribution board");
|
||||||
|
}
|
||||||
|
for (const list of snapshot.circuitLists) {
|
||||||
|
assertProjectOwnership(list.projectId, projectId, "circuit list");
|
||||||
|
assertReference(
|
||||||
|
boardIds,
|
||||||
|
list.distributionBoardId,
|
||||||
|
"circuit list distribution board"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const section of snapshot.circuitSections) {
|
||||||
|
assertReference(
|
||||||
|
circuitListIds,
|
||||||
|
section.circuitListId,
|
||||||
|
"circuit section list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const device of snapshot.projectDevices) {
|
||||||
|
assertProjectOwnership(device.projectId, projectId, "project device");
|
||||||
|
}
|
||||||
|
for (const floor of snapshot.floors) {
|
||||||
|
assertProjectOwnership(floor.projectId, projectId, "floor");
|
||||||
|
}
|
||||||
|
for (const room of snapshot.rooms) {
|
||||||
|
assertProjectOwnership(room.projectId, projectId, "room");
|
||||||
|
if (room.floorId !== null) {
|
||||||
|
assertReference(floorIds, room.floorId, "room floor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceRowIds = new Set<string>();
|
||||||
|
for (const circuit of snapshot.circuits) {
|
||||||
|
assertReference(
|
||||||
|
circuitListIds,
|
||||||
|
circuit.circuitListId,
|
||||||
|
"circuit list"
|
||||||
|
);
|
||||||
|
assertReference(sectionIds, circuit.sectionId, "circuit section");
|
||||||
|
const section = sectionById.get(circuit.sectionId)!;
|
||||||
|
if (section.circuitListId !== circuit.circuitListId) {
|
||||||
|
throw new Error(
|
||||||
|
"Snapshot circuit section belongs to a different circuit list."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
|
||||||
|
throw new Error(
|
||||||
|
"Snapshot circuit reserve state must match its device rows."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const row of circuit.deviceRows) {
|
||||||
|
if (row.circuitId !== circuit.id) {
|
||||||
|
throw new Error(
|
||||||
|
"Snapshot device row belongs to a different circuit."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (deviceRowIds.has(row.id)) {
|
||||||
|
throw new Error("Snapshot contains duplicate device row ids.");
|
||||||
|
}
|
||||||
|
deviceRowIds.add(row.id);
|
||||||
|
if (row.linkedProjectDeviceId !== null) {
|
||||||
|
assertReference(
|
||||||
|
projectDeviceIds,
|
||||||
|
row.linkedProjectDeviceId,
|
||||||
|
"device row project device"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.roomId !== null) {
|
||||||
|
assertReference(roomIds, row.roomId, "device row room");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueIds(
|
||||||
|
entries: ReadonlyArray<{ id: string }>,
|
||||||
|
label: string
|
||||||
|
) {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (ids.has(entry.id)) {
|
||||||
|
throw new Error(`Snapshot contains duplicate ${label} ids.`);
|
||||||
|
}
|
||||||
|
ids.add(entry.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertProjectOwnership(
|
||||||
|
actualProjectId: string,
|
||||||
|
expectedProjectId: string,
|
||||||
|
label: string
|
||||||
|
) {
|
||||||
|
if (actualProjectId !== expectedProjectId) {
|
||||||
|
throw new Error(`Snapshot ${label} belongs to a different project.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertReference(
|
||||||
|
ids: ReadonlySet<string>,
|
||||||
|
id: string,
|
||||||
|
label: string
|
||||||
|
) {
|
||||||
|
if (!ids.has(id)) {
|
||||||
|
throw new Error(`Snapshot ${label} reference is invalid.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
export interface CreateCircuitDeviceRowTransactionInput {
|
|
||||||
circuitId: string;
|
|
||||||
linkedProjectDeviceId?: string;
|
|
||||||
sortOrder?: number;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
phaseType?: string;
|
|
||||||
connectionKind?: string;
|
|
||||||
costGroup?: string;
|
|
||||||
category?: string;
|
|
||||||
level?: string;
|
|
||||||
roomId?: string;
|
|
||||||
roomNumberSnapshot?: string;
|
|
||||||
roomNameSnapshot?: string;
|
|
||||||
quantity: number;
|
|
||||||
powerPerUnit: number;
|
|
||||||
simultaneityFactor: number;
|
|
||||||
cosPhi?: number;
|
|
||||||
remark?: string;
|
|
||||||
overriddenFields?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateCircuitTransactionInput {
|
|
||||||
circuitListId: string;
|
|
||||||
sectionId: string;
|
|
||||||
equipmentIdentifier: string;
|
|
||||||
displayName?: string;
|
|
||||||
sortOrder: number;
|
|
||||||
protectionType?: string;
|
|
||||||
protectionRatedCurrent?: number;
|
|
||||||
protectionCharacteristic?: string;
|
|
||||||
cableType?: string;
|
|
||||||
cableCrossSection?: string;
|
|
||||||
cableLength?: number;
|
|
||||||
voltage?: number;
|
|
||||||
controlRequirement?: string;
|
|
||||||
remark?: string;
|
|
||||||
rcdAssignment?: string;
|
|
||||||
terminalDesignation?: string;
|
|
||||||
status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateCircuitWithDeviceRowsTransactionInput {
|
|
||||||
circuit: CreateCircuitTransactionInput;
|
|
||||||
deviceRows: Array<Omit<CreateCircuitDeviceRowTransactionInput, "circuitId">>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MoveCircuitDeviceRowsTransactionInput {
|
|
||||||
rows: Array<{ id: string; expectedCircuitId: string }>;
|
|
||||||
targetCircuitId?: string;
|
|
||||||
createTargetCircuit?: {
|
|
||||||
circuitListId: string;
|
|
||||||
sectionId: string;
|
|
||||||
equipmentIdentifier: string;
|
|
||||||
displayName: string;
|
|
||||||
sortOrder: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MoveCircuitDeviceRowsTransactionResult {
|
|
||||||
movedRowIds: string[];
|
|
||||||
targetCircuitId: string;
|
|
||||||
createdCircuitId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CircuitDeviceRowTransactionStore {
|
|
||||||
createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string;
|
|
||||||
createCircuitWithDeviceRows(
|
|
||||||
input: CreateCircuitWithDeviceRowsTransactionInput
|
|
||||||
): { circuitId: string; rowIds: string[] };
|
|
||||||
deleteFromCircuit(rowId: string, expectedCircuitId: string): void;
|
|
||||||
moveRows(
|
|
||||||
input: MoveCircuitDeviceRowsTransactionInput
|
|
||||||
): MoveCircuitDeviceRowsTransactionResult;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CircuitSectionReorderProjectCommand } from "../models/circuit-section-reorder-project-command.model.js";
|
import type { CircuitSectionReorderProjectCommand } from "../models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import type { CircuitSectionsReorderProjectCommand } from "../models/circuit-sections-reorder-project-command.model.js";
|
||||||
import type {
|
import type {
|
||||||
AppendedProjectRevision,
|
AppendedProjectRevision,
|
||||||
ProjectRevisionSource,
|
ProjectRevisionSource,
|
||||||
@@ -11,12 +12,16 @@ export interface ExecuteCircuitSectionReorderCommandInput {
|
|||||||
description?: string;
|
description?: string;
|
||||||
actorId?: string;
|
actorId?: string;
|
||||||
historyTargetChangeSetId?: string;
|
historyTargetChangeSetId?: string;
|
||||||
command: CircuitSectionReorderProjectCommand;
|
command:
|
||||||
|
| CircuitSectionReorderProjectCommand
|
||||||
|
| CircuitSectionsReorderProjectCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecutedCircuitSectionReorderCommand {
|
export interface ExecutedCircuitSectionReorderCommand {
|
||||||
revision: AppendedProjectRevision;
|
revision: AppendedProjectRevision;
|
||||||
inverse: CircuitSectionReorderProjectCommand;
|
inverse:
|
||||||
|
| CircuitSectionReorderProjectCommand
|
||||||
|
| CircuitSectionsReorderProjectCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CircuitSectionReorderProjectCommandStore {
|
export interface CircuitSectionReorderProjectCommandStore {
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export interface CircuitSectionTransactionStore {
|
|
||||||
updateEquipmentIdentifiers(
|
|
||||||
circuitListId: string,
|
|
||||||
updates: Array<{ id: string; equipmentIdentifier: string }>,
|
|
||||||
tempNamespace: string
|
|
||||||
): void;
|
|
||||||
updateSortOrders(sectionId: string, orderedCircuitIds: string[]): void;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteDistributionBoardStructureCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: DistributionBoardStructureProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedDistributionBoardStructureCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: DistributionBoardStructureProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionBoardStructureProjectCommandStore {
|
||||||
|
execute(
|
||||||
|
input: ExecuteDistributionBoardStructureCommandInput
|
||||||
|
): ExecutedDistributionBoardStructureCommand;
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ export interface ProjectCommandExecutor {
|
|||||||
executeUser(
|
executeUser(
|
||||||
input: ExecuteUserProjectCommandInput
|
input: ExecuteUserProjectCommandInput
|
||||||
): ExecutedProjectCommand;
|
): ExecutedProjectCommand;
|
||||||
|
executeRestore(
|
||||||
|
input: ExecuteUserProjectCommandInput
|
||||||
|
): ExecutedProjectCommand;
|
||||||
undo(
|
undo(
|
||||||
input: ExecuteProjectHistoryCommandInput
|
input: ExecuteProjectHistoryCommandInput
|
||||||
): ExecutedProjectCommand;
|
): ExecutedProjectCommand;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SerializedProjectCommand } from "../models/project-command.model.js";
|
import type { SerializedProjectCommand } from "../models/project-command.model.js";
|
||||||
|
import type { ProjectRevisionSource } from "./project-revision.store.js";
|
||||||
|
|
||||||
export type ProjectHistoryDirection = "undo" | "redo";
|
export type ProjectHistoryDirection = "undo" | "redo";
|
||||||
|
|
||||||
@@ -16,8 +17,36 @@ export interface ProjectHistoryCommand {
|
|||||||
command: SerializedProjectCommand;
|
command: SerializedProjectCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectRevisionSummary {
|
||||||
|
revisionId: string;
|
||||||
|
changeSetId: string;
|
||||||
|
revisionNumber: number;
|
||||||
|
createdAtIso: string;
|
||||||
|
actorId: string | null;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description: string | null;
|
||||||
|
commandType: string;
|
||||||
|
payloadSchemaVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectRevisionPage {
|
||||||
|
projectId: string;
|
||||||
|
currentRevision: number;
|
||||||
|
revisions: ProjectRevisionSummary[];
|
||||||
|
nextBeforeRevision: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListProjectRevisionsInput {
|
||||||
|
projectId: string;
|
||||||
|
limit: number;
|
||||||
|
beforeRevision?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectHistoryStore {
|
export interface ProjectHistoryStore {
|
||||||
getState(projectId: string): ProjectHistoryState | null;
|
getState(projectId: string): ProjectHistoryState | null;
|
||||||
|
listRevisions(
|
||||||
|
input: ListProjectRevisionsInput
|
||||||
|
): ProjectRevisionPage | null;
|
||||||
getNextCommand(
|
getNextCommand(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
direction: ProjectHistoryDirection
|
direction: ProjectHistoryDirection
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ProjectSettingsUpdateProjectCommand } from "../models/project-settings-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteProjectSettingsUpdateCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: ProjectSettingsUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedProjectSettingsUpdateCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: ProjectSettingsUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSettingsProjectCommandStore {
|
||||||
|
executeUpdate(
|
||||||
|
input: ExecuteProjectSettingsUpdateCommandInput
|
||||||
|
): ExecutedProjectSettingsUpdateCommand;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ProjectSnapshotKind } from "../models/project-snapshot-policy.model.js";
|
||||||
|
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
|
||||||
|
|
||||||
|
export interface ProjectSnapshotMetadata {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
sourceRevision: number;
|
||||||
|
schemaVersion: number;
|
||||||
|
kind: ProjectSnapshotKind;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
payloadSha256: string;
|
||||||
|
createdAtIso: string;
|
||||||
|
createdByActorId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateNamedProjectSnapshotInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedProjectSnapshotRestore {
|
||||||
|
snapshot: ProjectSnapshotMetadata;
|
||||||
|
command: ProjectStateRestoreCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSnapshotStore {
|
||||||
|
createNamed(
|
||||||
|
input: CreateNamedProjectSnapshotInput
|
||||||
|
): ProjectSnapshotMetadata | null;
|
||||||
|
listByProject(projectId: string): ProjectSnapshotMetadata[] | null;
|
||||||
|
prepareRestore(
|
||||||
|
projectId: string,
|
||||||
|
snapshotId: string
|
||||||
|
): PreparedProjectSnapshotRestore | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteProjectStateRestoreCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: ProjectStateRestoreCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedProjectStateRestoreCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: ProjectStateRestoreCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectStateRestoreCommandStore {
|
||||||
|
execute(
|
||||||
|
input: ExecuteProjectStateRestoreCommandInput
|
||||||
|
): ExecutedProjectStateRestoreCommand;
|
||||||
|
}
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
|
||||||
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
|
|
||||||
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js";
|
|
||||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
|
||||||
import type {
|
|
||||||
MoveCircuitDeviceRowInput,
|
|
||||||
MoveCircuitDeviceRowsBulkInput,
|
|
||||||
ReorderSectionCircuitsInput,
|
|
||||||
UpdateSectionEquipmentIdentifiersInput,
|
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
|
||||||
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
|
||||||
|
|
||||||
export class CircuitWriteService {
|
|
||||||
private readonly circuitRepository: CircuitRepository;
|
|
||||||
private readonly circuitSectionRepository: CircuitSectionRepository;
|
|
||||||
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
|
||||||
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
|
||||||
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
|
||||||
private readonly numberingService: CircuitNumberingService;
|
|
||||||
|
|
||||||
constructor(deps?: {
|
|
||||||
circuitRepository?: CircuitRepository;
|
|
||||||
circuitSectionRepository?: CircuitSectionRepository;
|
|
||||||
deviceRowRepository?: CircuitDeviceRowRepository;
|
|
||||||
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
|
||||||
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
|
||||||
numberingService?: CircuitNumberingService;
|
|
||||||
}) {
|
|
||||||
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
|
|
||||||
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
|
|
||||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
|
||||||
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
|
|
||||||
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
|
|
||||||
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensures writes never connect a section to the wrong circuit list.
|
|
||||||
private async assertSectionInList(sectionId: string, circuitListId: string) {
|
|
||||||
const section = await this.circuitSectionRepository.findById(sectionId);
|
|
||||||
if (!section) {
|
|
||||||
throw new Error("Invalid section id.");
|
|
||||||
}
|
|
||||||
if (section.circuitListId !== circuitListId) {
|
|
||||||
throw new Error("Section does not belong to circuit list.");
|
|
||||||
}
|
|
||||||
return section;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDeviceRowTransactionStore() {
|
|
||||||
if (!this.deviceRowTransactionStore) {
|
|
||||||
throw new Error("Circuit device-row transactions are not configured.");
|
|
||||||
}
|
|
||||||
return this.deviceRowTransactionStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCircuitSectionTransactionStore() {
|
|
||||||
if (!this.circuitSectionTransactionStore) {
|
|
||||||
throw new Error("Circuit-section transactions are not configured.");
|
|
||||||
}
|
|
||||||
return this.circuitSectionTransactionStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteCircuit(circuitId: string) {
|
|
||||||
const current = await this.circuitRepository.findById(circuitId);
|
|
||||||
if (!current) {
|
|
||||||
throw new Error("Invalid circuit id.");
|
|
||||||
}
|
|
||||||
await this.circuitRepository.delete(circuitId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
|
|
||||||
const row = await this.deviceRowRepository.findById(rowId);
|
|
||||||
if (!row) {
|
|
||||||
throw new Error("Invalid device row id.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceCircuit = await this.circuitRepository.findById(row.circuitId);
|
|
||||||
if (!sourceCircuit) {
|
|
||||||
throw new Error("Invalid circuit id.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetCircuit = input.targetCircuitId
|
|
||||||
? await this.circuitRepository.findById(input.targetCircuitId)
|
|
||||||
: null;
|
|
||||||
if (input.targetCircuitId && !targetCircuit) {
|
|
||||||
throw new Error("Invalid target circuit id.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let createTargetCircuit:
|
|
||||||
| {
|
|
||||||
circuitListId: string;
|
|
||||||
sectionId: string;
|
|
||||||
equipmentIdentifier: string;
|
|
||||||
displayName: string;
|
|
||||||
sortOrder: number;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
// Placeholder-target move prepares a new circuit explicitly. Its creation and
|
|
||||||
// the row assignment are committed together without renumbering other circuits.
|
|
||||||
if (!targetCircuit) {
|
|
||||||
if (!input.targetSectionId || !input.createNewCircuit) {
|
|
||||||
throw new Error("Invalid move target.");
|
|
||||||
}
|
|
||||||
const section = await this.assertSectionInList(input.targetSectionId, sourceCircuit.circuitListId);
|
|
||||||
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
|
|
||||||
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
|
|
||||||
const nextSortOrder =
|
|
||||||
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
|
||||||
createTargetCircuit = {
|
|
||||||
circuitListId: sourceCircuit.circuitListId,
|
|
||||||
sectionId: section.id,
|
|
||||||
equipmentIdentifier: nextIdentifier,
|
|
||||||
displayName: "Neuer Stromkreis",
|
|
||||||
sortOrder: nextSortOrder,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetCircuit && targetCircuit.circuitListId !== sourceCircuit.circuitListId) {
|
|
||||||
throw new Error("Target circuit does not belong to same circuit list.");
|
|
||||||
}
|
|
||||||
if (targetCircuit?.id === sourceCircuit.id) {
|
|
||||||
return this.deviceRowRepository.findById(rowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.getDeviceRowTransactionStore().moveRows({
|
|
||||||
rows: [{ id: row.id, expectedCircuitId: row.circuitId }],
|
|
||||||
targetCircuitId: targetCircuit?.id,
|
|
||||||
createTargetCircuit,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.deviceRowRepository.findById(rowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) {
|
|
||||||
// Bulk move keeps input order and resolves all source circuits first so undo can
|
|
||||||
// restore per-source assignment deterministically.
|
|
||||||
const uniqueRowIds = [...new Set(input.rowIds)];
|
|
||||||
if (uniqueRowIds.length === 0) {
|
|
||||||
throw new Error("No device rows provided.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = [];
|
|
||||||
for (const rowId of uniqueRowIds) {
|
|
||||||
const row = await this.deviceRowRepository.findById(rowId);
|
|
||||||
if (!row) {
|
|
||||||
throw new Error("Invalid device row id.");
|
|
||||||
}
|
|
||||||
rows.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceCircuits = new Map<string, Awaited<ReturnType<CircuitRepository["findById"]>>>();
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!sourceCircuits.has(row.circuitId)) {
|
|
||||||
const circuit = await this.circuitRepository.findById(row.circuitId);
|
|
||||||
if (!circuit) {
|
|
||||||
throw new Error("Invalid circuit id.");
|
|
||||||
}
|
|
||||||
sourceCircuits.set(row.circuitId, circuit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const referenceSourceCircuit = sourceCircuits.get(rows[0].circuitId)!;
|
|
||||||
let targetCircuit = input.targetCircuitId
|
|
||||||
? await this.circuitRepository.findById(input.targetCircuitId)
|
|
||||||
: null;
|
|
||||||
if (input.targetCircuitId && !targetCircuit) {
|
|
||||||
throw new Error("Invalid target circuit id.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk placeholder move prepares exactly one new circuit as common target.
|
|
||||||
// Its actual creation happens together with the row moves in one transaction.
|
|
||||||
let createTargetCircuit:
|
|
||||||
| {
|
|
||||||
circuitListId: string;
|
|
||||||
sectionId: string;
|
|
||||||
equipmentIdentifier: string;
|
|
||||||
displayName: string;
|
|
||||||
sortOrder: number;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
if (!targetCircuit) {
|
|
||||||
if (!input.targetSectionId || !input.createNewCircuit) {
|
|
||||||
throw new Error("Invalid move target.");
|
|
||||||
}
|
|
||||||
const section = await this.assertSectionInList(input.targetSectionId, referenceSourceCircuit.circuitListId);
|
|
||||||
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
|
|
||||||
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
|
|
||||||
const nextSortOrder =
|
|
||||||
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
|
||||||
createTargetCircuit = {
|
|
||||||
circuitListId: referenceSourceCircuit.circuitListId,
|
|
||||||
sectionId: section.id,
|
|
||||||
equipmentIdentifier: nextIdentifier,
|
|
||||||
displayName: "Neuer Stromkreis",
|
|
||||||
sortOrder: nextSortOrder,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetCircuitListId = targetCircuit?.circuitListId ?? createTargetCircuit!.circuitListId;
|
|
||||||
for (const sourceCircuit of sourceCircuits.values()) {
|
|
||||||
if (sourceCircuit.circuitListId !== targetCircuitListId) {
|
|
||||||
throw new Error("All moved rows must belong to same circuit list as target.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getDeviceRowTransactionStore().moveRows({
|
|
||||||
rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })),
|
|
||||||
targetCircuitId: targetCircuit?.id,
|
|
||||||
createTargetCircuit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNextIdentifier(sectionId: string) {
|
|
||||||
return this.numberingService.getNextIdentifier(sectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async renumberSection(sectionId: string) {
|
|
||||||
// Explicit renumber operation for one section only.
|
|
||||||
// Never renumbers other sections and never runs implicitly during move/sort operations.
|
|
||||||
const section = await this.circuitSectionRepository.findById(sectionId);
|
|
||||||
if (!section) {
|
|
||||||
throw new Error("Invalid section id.");
|
|
||||||
}
|
|
||||||
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
|
|
||||||
const otherCircuits = (await this.circuitRepository.listByCircuitList(section.circuitListId)).filter(
|
|
||||||
(circuit) => circuit.sectionId !== sectionId
|
|
||||||
);
|
|
||||||
const otherIdentifiers = new Set(otherCircuits.map((circuit) => circuit.equipmentIdentifier));
|
|
||||||
|
|
||||||
const finalAssignments: Array<{ id: string; equipmentIdentifier: string }> = [];
|
|
||||||
let index = 1;
|
|
||||||
for (const circuit of sectionCircuits) {
|
|
||||||
let candidate = `${section.prefix}${index}`;
|
|
||||||
while (otherIdentifiers.has(candidate)) {
|
|
||||||
index += 1;
|
|
||||||
candidate = `${section.prefix}${index}`;
|
|
||||||
}
|
|
||||||
finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate });
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
|
|
||||||
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
|
|
||||||
section.circuitListId,
|
|
||||||
finalAssignments,
|
|
||||||
sectionId
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.circuitRepository.listBySection(sectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSectionEquipmentIdentifiers(
|
|
||||||
sectionId: string,
|
|
||||||
input: UpdateSectionEquipmentIdentifiersInput
|
|
||||||
) {
|
|
||||||
const section = await this.circuitSectionRepository.findById(sectionId);
|
|
||||||
if (!section) {
|
|
||||||
throw new Error("Invalid section id.");
|
|
||||||
}
|
|
||||||
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
|
|
||||||
const sectionIds = new Set(sectionCircuits.map((circuit) => circuit.id));
|
|
||||||
if (input.identifiers.length !== sectionCircuits.length) {
|
|
||||||
throw new Error("identifiers must include all circuits in the section.");
|
|
||||||
}
|
|
||||||
for (const entry of input.identifiers) {
|
|
||||||
if (!sectionIds.has(entry.circuitId)) {
|
|
||||||
throw new Error("Circuit id does not belong to section.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
|
|
||||||
section.circuitListId,
|
|
||||||
input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })),
|
|
||||||
sectionId
|
|
||||||
);
|
|
||||||
return this.circuitRepository.listBySection(sectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) {
|
|
||||||
// Reorder updates sortOrder only. BMKs remain unchanged; users may renumber explicitly later.
|
|
||||||
const section = await this.circuitSectionRepository.findById(sectionId);
|
|
||||||
if (!section) {
|
|
||||||
throw new Error("Invalid section id.");
|
|
||||||
}
|
|
||||||
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
|
|
||||||
const sectionIds = new Set(sectionCircuits.map((circuit) => circuit.id));
|
|
||||||
if (sectionCircuits.length !== input.orderedCircuitIds.length) {
|
|
||||||
throw new Error("orderedCircuitIds must include all circuits of the section.");
|
|
||||||
}
|
|
||||||
for (const circuitId of input.orderedCircuitIds) {
|
|
||||||
if (!sectionIds.has(circuitId)) {
|
|
||||||
throw new Error("Circuit id does not belong to section.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.getCircuitSectionTransactionStore().updateSortOrders(
|
|
||||||
sectionId,
|
|
||||||
input.orderedCircuitIds
|
|
||||||
);
|
|
||||||
return this.circuitRepository.listBySection(sectionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
assertCircuitSectionReorderProjectCommand,
|
assertCircuitSectionReorderProjectCommand,
|
||||||
circuitSectionReorderCommandType,
|
circuitSectionReorderCommandType,
|
||||||
} from "../models/circuit-section-reorder-project-command.model.js";
|
} from "../models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitSectionsReorderProjectCommand,
|
||||||
|
circuitSectionsReorderCommandType,
|
||||||
|
} from "../models/circuit-sections-reorder-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
assertCircuitSectionRenumberProjectCommand,
|
assertCircuitSectionRenumberProjectCommand,
|
||||||
circuitSectionRenumberCommandType,
|
circuitSectionRenumberCommandType,
|
||||||
@@ -33,10 +37,24 @@ import {
|
|||||||
circuitDeleteCommandType,
|
circuitDeleteCommandType,
|
||||||
circuitInsertCommandType,
|
circuitInsertCommandType,
|
||||||
} from "../models/circuit-structure-project-command.model.js";
|
} from "../models/circuit-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertDistributionBoardDeleteProjectCommand,
|
||||||
|
assertDistributionBoardInsertProjectCommand,
|
||||||
|
distributionBoardDeleteCommandType,
|
||||||
|
distributionBoardInsertCommandType,
|
||||||
|
} from "../models/distribution-board-structure-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
assertSerializedProjectCommand,
|
assertSerializedProjectCommand,
|
||||||
type SerializedProjectCommand,
|
type SerializedProjectCommand,
|
||||||
} from "../models/project-command.model.js";
|
} from "../models/project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertProjectStateRestoreCommand,
|
||||||
|
projectStateRestoreCommandType,
|
||||||
|
} from "../models/project-state-restore-command.model.js";
|
||||||
|
import {
|
||||||
|
assertProjectSettingsUpdateProjectCommand,
|
||||||
|
projectSettingsUpdateCommandType,
|
||||||
|
} from "../models/project-settings-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
assertProjectDeviceRowSyncProjectCommand,
|
assertProjectDeviceRowSyncProjectCommand,
|
||||||
projectDeviceRowSyncCommandType,
|
projectDeviceRowSyncCommandType,
|
||||||
@@ -58,6 +76,7 @@ import type { CircuitProjectCommandStore } from "../ports/circuit-project-comman
|
|||||||
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
|
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
|
||||||
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
||||||
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
||||||
|
import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
|
||||||
import type {
|
import type {
|
||||||
ExecuteProjectHistoryCommandInput,
|
ExecuteProjectHistoryCommandInput,
|
||||||
ExecutedProjectCommand,
|
ExecutedProjectCommand,
|
||||||
@@ -72,6 +91,8 @@ import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-p
|
|||||||
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";
|
||||||
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
|
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
|
||||||
|
import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js";
|
||||||
|
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
|
||||||
|
|
||||||
interface DispatchProjectCommandInput {
|
interface DispatchProjectCommandInput {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -80,7 +101,7 @@ interface DispatchProjectCommandInput {
|
|||||||
description?: string;
|
description?: string;
|
||||||
actorId?: string;
|
actorId?: string;
|
||||||
historyTargetChangeSetId?: string;
|
historyTargetChangeSetId?: string;
|
||||||
command: SerializedProjectCommand;
|
command: SerializedProjectCommand<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectCommandService implements ProjectCommandExecutor {
|
export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
@@ -90,11 +111,14 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
|
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
|
||||||
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
|
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
|
||||||
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
|
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
|
||||||
|
private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore,
|
||||||
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
|
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
|
||||||
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
|
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
|
||||||
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
|
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
|
||||||
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
|
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
|
||||||
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
|
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
|
||||||
|
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
|
||||||
|
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
|
||||||
private readonly historyStore: ProjectHistoryStore
|
private readonly historyStore: ProjectHistoryStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -103,6 +127,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
): ExecutedProjectCommand {
|
): ExecutedProjectCommand {
|
||||||
assertExpectedRevision(input.expectedRevision);
|
assertExpectedRevision(input.expectedRevision);
|
||||||
assertSerializedProjectCommand(input.command);
|
assertSerializedProjectCommand(input.command);
|
||||||
|
if (input.command.type === projectStateRestoreCommandType) {
|
||||||
|
throw new Error(
|
||||||
|
"Project state restore commands require a stored server snapshot."
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.dispatch({
|
return this.dispatch({
|
||||||
...input,
|
...input,
|
||||||
source: "user",
|
source: "user",
|
||||||
@@ -110,6 +139,18 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
executeRestore(
|
||||||
|
input: ExecuteUserProjectCommandInput
|
||||||
|
): ExecutedProjectCommand {
|
||||||
|
assertExpectedRevision(input.expectedRevision);
|
||||||
|
assertProjectStateRestoreCommand(input.command);
|
||||||
|
return this.dispatch({
|
||||||
|
...input,
|
||||||
|
source: "restore",
|
||||||
|
command: input.command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
undo(
|
undo(
|
||||||
input: ExecuteProjectHistoryCommandInput
|
input: ExecuteProjectHistoryCommandInput
|
||||||
): ExecutedProjectCommand {
|
): ExecutedProjectCommand {
|
||||||
@@ -218,6 +259,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case distributionBoardInsertCommandType: {
|
||||||
|
assertDistributionBoardInsertProjectCommand(input.command);
|
||||||
|
return this.distributionBoardStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case distributionBoardDeleteCommandType: {
|
||||||
|
assertDistributionBoardDeleteProjectCommand(input.command);
|
||||||
|
return this.distributionBoardStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
case circuitSectionReorderCommandType: {
|
case circuitSectionReorderCommandType: {
|
||||||
assertCircuitSectionReorderProjectCommand(input.command);
|
assertCircuitSectionReorderProjectCommand(input.command);
|
||||||
return this.circuitSectionReorderStore.execute({
|
return this.circuitSectionReorderStore.execute({
|
||||||
@@ -225,6 +280,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case circuitSectionsReorderCommandType: {
|
||||||
|
assertCircuitSectionsReorderProjectCommand(input.command);
|
||||||
|
return this.circuitSectionReorderStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
case circuitSectionRenumberCommandType: {
|
case circuitSectionRenumberCommandType: {
|
||||||
assertCircuitSectionRenumberProjectCommand(input.command);
|
assertCircuitSectionRenumberProjectCommand(input.command);
|
||||||
return this.circuitSectionRenumberStore.execute({
|
return this.circuitSectionRenumberStore.execute({
|
||||||
@@ -260,6 +322,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case projectSettingsUpdateCommandType: {
|
||||||
|
assertProjectSettingsUpdateProjectCommand(input.command);
|
||||||
|
return this.projectSettingsStore.executeUpdate({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case projectStateRestoreCommandType: {
|
||||||
|
assertProjectStateRestoreCommand(input.command);
|
||||||
|
return this.projectStateRestoreStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unsupported project command type: ${input.command.type}.`
|
`Unsupported project command type: ${input.command.type}.`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,504 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
FormEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import type {
|
||||||
|
ProjectHistoryStateDto,
|
||||||
|
ProjectRevisionPageDto,
|
||||||
|
ProjectRevisionSummaryDto,
|
||||||
|
ProjectSnapshotMetadataDto,
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
createNamedProjectSnapshot,
|
||||||
|
getProjectHistory,
|
||||||
|
listProjectRevisions,
|
||||||
|
listProjectSnapshots,
|
||||||
|
redoProjectCommand,
|
||||||
|
restoreProjectSnapshot,
|
||||||
|
undoProjectCommand,
|
||||||
|
} from "../utils/api";
|
||||||
|
import {
|
||||||
|
getProjectRevisionDescription,
|
||||||
|
getProjectRevisionSourceLabel,
|
||||||
|
getProjectSnapshotKindLabel,
|
||||||
|
mergeProjectRevisionPages,
|
||||||
|
} from "../utils/project-version-history";
|
||||||
|
|
||||||
|
interface ProjectVersionHistoryProps {
|
||||||
|
projectId: string;
|
||||||
|
currentRevision: number;
|
||||||
|
onProjectStateChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionPageSize = 10;
|
||||||
|
|
||||||
|
export function ProjectVersionHistory({
|
||||||
|
projectId,
|
||||||
|
currentRevision,
|
||||||
|
onProjectStateChange,
|
||||||
|
}: ProjectVersionHistoryProps) {
|
||||||
|
const [history, setHistory] =
|
||||||
|
useState<ProjectHistoryStateDto | null>(null);
|
||||||
|
const [snapshots, setSnapshots] = useState<ProjectSnapshotMetadataDto[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [revisions, setRevisions] = useState<ProjectRevisionSummaryDto[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [nextBeforeRevision, setNextBeforeRevision] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const [snapshotName, setSnapshotName] = useState("");
|
||||||
|
const [snapshotDescription, setSnapshotDescription] = useState("");
|
||||||
|
const [restoreCandidate, setRestoreCandidate] =
|
||||||
|
useState<ProjectSnapshotMetadataDto | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isHistoryLoading, setIsHistoryLoading] = useState(true);
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async () => {
|
||||||
|
setIsHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
setHistory(await getProjectHistory(projectId));
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(
|
||||||
|
getVersionHistoryError(
|
||||||
|
loadError,
|
||||||
|
"Rückgängig-Status konnte nicht geladen werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const loadOverview = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [loadedSnapshots, revisionPage] = await Promise.all([
|
||||||
|
listProjectSnapshots(projectId),
|
||||||
|
listProjectRevisions(projectId, { limit: revisionPageSize }),
|
||||||
|
]);
|
||||||
|
setSnapshots(loadedSnapshots);
|
||||||
|
setRevisions(revisionPage.revisions);
|
||||||
|
setNextBeforeRevision(revisionPage.nextBeforeRevision);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(
|
||||||
|
getVersionHistoryError(
|
||||||
|
loadError,
|
||||||
|
"Versionshistorie konnte nicht geladen werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadHistory();
|
||||||
|
}, [currentRevision, loadHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void loadOverview();
|
||||||
|
}
|
||||||
|
}, [loadOverview, currentRevision, isOpen]);
|
||||||
|
|
||||||
|
async function handleCreateSnapshot(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = snapshotName.trim();
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const created = await createNamedProjectSnapshot(
|
||||||
|
projectId,
|
||||||
|
currentRevision,
|
||||||
|
name,
|
||||||
|
snapshotDescription.trim() || undefined
|
||||||
|
);
|
||||||
|
setSnapshots((current) => [created, ...current]);
|
||||||
|
setSnapshotName("");
|
||||||
|
setSnapshotDescription("");
|
||||||
|
} catch (createError) {
|
||||||
|
setError(
|
||||||
|
getVersionHistoryError(
|
||||||
|
createError,
|
||||||
|
"Projektstand konnte nicht gespeichert werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLoadOlderRevisions() {
|
||||||
|
if (nextBeforeRevision === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const page: ProjectRevisionPageDto = await listProjectRevisions(
|
||||||
|
projectId,
|
||||||
|
{
|
||||||
|
limit: revisionPageSize,
|
||||||
|
beforeRevision: nextBeforeRevision,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setRevisions((current) =>
|
||||||
|
mergeProjectRevisionPages(current, page.revisions)
|
||||||
|
);
|
||||||
|
setNextBeforeRevision(page.nextBeforeRevision);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(
|
||||||
|
getVersionHistoryError(
|
||||||
|
loadError,
|
||||||
|
"Ältere Revisionen konnten nicht geladen werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestoreSnapshot() {
|
||||||
|
if (!restoreCandidate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await restoreProjectSnapshot(
|
||||||
|
projectId,
|
||||||
|
restoreCandidate.id,
|
||||||
|
currentRevision
|
||||||
|
);
|
||||||
|
setRestoreCandidate(null);
|
||||||
|
setIsBusy(false);
|
||||||
|
onProjectStateChange();
|
||||||
|
} catch (restoreError) {
|
||||||
|
setError(
|
||||||
|
getVersionHistoryError(
|
||||||
|
restoreError,
|
||||||
|
"Projektstand konnte nicht wiederhergestellt werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setRestoreCandidate(null);
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHistoryCommand(direction: "undo" | "redo") {
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
direction === "undo"
|
||||||
|
? await undoProjectCommand(projectId, currentRevision)
|
||||||
|
: await redoProjectCommand(projectId, currentRevision);
|
||||||
|
setHistory(result.history);
|
||||||
|
onProjectStateChange();
|
||||||
|
} catch (historyError) {
|
||||||
|
setError(
|
||||||
|
getVersionHistoryError(
|
||||||
|
historyError,
|
||||||
|
direction === "undo"
|
||||||
|
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
|
||||||
|
: "Die letzte Änderung konnte nicht wiederholt werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyIsCurrent =
|
||||||
|
history?.currentRevision === currentRevision;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card shadow-sm">
|
||||||
|
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<span>Versionen und Sicherungspunkte</span>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span className="badge text-bg-secondary">
|
||||||
|
Aktuelle Revision {currentRevision}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
disabled={
|
||||||
|
isBusy ||
|
||||||
|
isHistoryLoading ||
|
||||||
|
!historyIsCurrent ||
|
||||||
|
!history?.undoDepth
|
||||||
|
}
|
||||||
|
onClick={() => void handleHistoryCommand("undo")}
|
||||||
|
title={
|
||||||
|
history?.undoDepth
|
||||||
|
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
||||||
|
: "Keine Änderung zum Rückgängigmachen"
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Rückgängig
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
disabled={
|
||||||
|
isBusy ||
|
||||||
|
isHistoryLoading ||
|
||||||
|
!historyIsCurrent ||
|
||||||
|
!history?.redoDepth
|
||||||
|
}
|
||||||
|
onClick={() => void handleHistoryCommand("redo")}
|
||||||
|
title={
|
||||||
|
history?.redoDepth
|
||||||
|
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
||||||
|
: "Keine Änderung zum Wiederholen"
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Wiederholen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isOpen ? "Ausblenden" : "Anzeigen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-warning m-3 mb-0" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isOpen ? (
|
||||||
|
<>
|
||||||
|
<div className="card-body border-bottom">
|
||||||
|
<h2 className="h6">Neuen Sicherungspunkt anlegen</h2>
|
||||||
|
<p className="small text-secondary">
|
||||||
|
Ein Sicherungspunkt speichert den vollständigen Projektstand, ohne
|
||||||
|
die Revision oder Rückgängig-Historie zu verändern.
|
||||||
|
</p>
|
||||||
|
<form className="row g-2" onSubmit={handleCreateSnapshot}>
|
||||||
|
<div className="col-12 col-lg-4">
|
||||||
|
<label className="form-label" htmlFor="snapshot-name">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="snapshot-name"
|
||||||
|
maxLength={100}
|
||||||
|
placeholder="z. B. Vor Planfreigabe"
|
||||||
|
value={snapshotName}
|
||||||
|
onChange={(event) => setSnapshotName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-lg-6">
|
||||||
|
<label className="form-label" htmlFor="snapshot-description">
|
||||||
|
Beschreibung (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="snapshot-description"
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="Was kennzeichnet diesen Stand?"
|
||||||
|
value={snapshotDescription}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSnapshotDescription(event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-lg-2 d-flex align-items-end">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary w-100"
|
||||||
|
disabled={isBusy || !snapshotName.trim()}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Stand speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body border-bottom">
|
||||||
|
<h2 className="h6">Gespeicherte Sicherungspunkte</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary mb-0">Sicherungspunkte werden geladen …</p>
|
||||||
|
) : snapshots.length ? (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Projektstand</th>
|
||||||
|
<th>Erstellt</th>
|
||||||
|
<th className="text-end">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{snapshots.map((snapshot) => (
|
||||||
|
<tr key={snapshot.id}>
|
||||||
|
<td>
|
||||||
|
<strong>{snapshot.name}</strong>
|
||||||
|
<span
|
||||||
|
className={`badge ms-2 ${
|
||||||
|
snapshot.kind === "automatic"
|
||||||
|
? "text-bg-info"
|
||||||
|
: "text-bg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getProjectSnapshotKindLabel(snapshot.kind)}
|
||||||
|
</span>
|
||||||
|
{snapshot.description ? (
|
||||||
|
<div className="small text-secondary">
|
||||||
|
{snapshot.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>Revision {snapshot.sourceRevision}</td>
|
||||||
|
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => setRestoreCandidate(snapshot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Wiederherstellen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
Noch keine Sicherungspunkte vorhanden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{restoreCandidate ? (
|
||||||
|
<div className="alert alert-danger mt-3 mb-0" role="alert">
|
||||||
|
<h3 className="h6">
|
||||||
|
„{restoreCandidate.name}“ wirklich wiederherstellen?
|
||||||
|
</h3>
|
||||||
|
<p className="mb-2">
|
||||||
|
Verteiler, Stromkreise, Projektgeräte, Räume und
|
||||||
|
Projekteinstellungen werden auf Revision{" "}
|
||||||
|
{restoreCandidate.sourceRevision} zurückgesetzt. Der aktuelle
|
||||||
|
Stand bleibt als neue rückgängig machbare Revision erhalten.
|
||||||
|
</p>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void handleRestoreSnapshot()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Jetzt wiederherstellen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => setRestoreCandidate(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="h6">Letzte Änderungen</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary mb-0">Historie wird geladen …</p>
|
||||||
|
) : revisions.length ? (
|
||||||
|
<>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Revision</th>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Art</th>
|
||||||
|
<th>Änderung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{revisions.map((revision) => (
|
||||||
|
<tr key={revision.revisionId}>
|
||||||
|
<td>{revision.revisionNumber}</td>
|
||||||
|
<td>{formatDateTime(revision.createdAtIso)}</td>
|
||||||
|
<td>
|
||||||
|
{getProjectRevisionSourceLabel(revision.source)}
|
||||||
|
</td>
|
||||||
|
<td>{getProjectRevisionDescription(revision)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{nextBeforeRevision !== null ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-secondary mt-3"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void handleLoadOlderRevisions()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Ältere Änderungen laden
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
Für dieses Projekt wurden noch keine versionierten Änderungen
|
||||||
|
gespeichert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVersionHistoryError(error: unknown, fallback: string) {
|
||||||
|
const details = error instanceof Error ? error.message : "";
|
||||||
|
if (details.includes("PROJECT_SNAPSHOT_NAME_CONFLICT")) {
|
||||||
|
return "Ein Sicherungspunkt mit diesem Namen ist bereits vorhanden.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
details.includes("PROJECT_REVISION_CONFLICT") ||
|
||||||
|
details.includes("PROJECT_STATE_CONFLICT")
|
||||||
|
) {
|
||||||
|
return "Das Projekt wurde inzwischen geändert. Bitte lade die Seite neu und versuche es erneut.";
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -15,6 +15,55 @@ export interface ProjectHistoryStateDto {
|
|||||||
redoChangeSetId: string | null;
|
redoChangeSetId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProjectRevisionSourceDto =
|
||||||
|
| "user"
|
||||||
|
| "undo"
|
||||||
|
| "redo"
|
||||||
|
| "restore"
|
||||||
|
| "migration";
|
||||||
|
|
||||||
|
export interface ProjectRevisionSummaryDto {
|
||||||
|
revisionId: string;
|
||||||
|
changeSetId: string;
|
||||||
|
revisionNumber: number;
|
||||||
|
createdAtIso: string;
|
||||||
|
actorId: string | null;
|
||||||
|
source: ProjectRevisionSourceDto;
|
||||||
|
description: string | null;
|
||||||
|
commandType: string;
|
||||||
|
payloadSchemaVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectRevisionPageDto {
|
||||||
|
projectId: string;
|
||||||
|
currentRevision: number;
|
||||||
|
revisions: ProjectRevisionSummaryDto[];
|
||||||
|
nextBeforeRevision: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSnapshotMetadataDto {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
sourceRevision: number;
|
||||||
|
schemaVersion: number;
|
||||||
|
kind: "named" | "automatic";
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
payloadSha256: string;
|
||||||
|
createdAtIso: string;
|
||||||
|
createdByActorId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSnapshotRestoreResultDto
|
||||||
|
extends ProjectCommandResultDto {
|
||||||
|
snapshot: ProjectSnapshotMetadataDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSettingsCommandResultDto
|
||||||
|
extends ProjectCommandResultDto {
|
||||||
|
project: ProjectDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectCommandDto {
|
export interface ProjectCommandDto {
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -38,6 +87,11 @@ export interface DistributionBoardDto {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DistributionBoardCommandResultDto
|
||||||
|
extends ProjectCommandResultDto {
|
||||||
|
distributionBoard: DistributionBoardDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CircuitListDto {
|
export interface CircuitListDto {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|||||||
+181
-54
@@ -5,6 +5,7 @@ import type {
|
|||||||
CreateRoomInput,
|
CreateRoomInput,
|
||||||
CreateGlobalDeviceInput,
|
CreateGlobalDeviceInput,
|
||||||
DistributionBoardDto,
|
DistributionBoardDto,
|
||||||
|
DistributionBoardCommandResultDto,
|
||||||
FloorDto,
|
FloorDto,
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectCommandDto,
|
ProjectCommandDto,
|
||||||
@@ -12,6 +13,10 @@ import type {
|
|||||||
ProjectDeviceCommandResultDto,
|
ProjectDeviceCommandResultDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
ProjectHistoryStateDto,
|
ProjectHistoryStateDto,
|
||||||
|
ProjectRevisionPageDto,
|
||||||
|
ProjectSnapshotMetadataDto,
|
||||||
|
ProjectSnapshotRestoreResultDto,
|
||||||
|
ProjectSettingsCommandResultDto,
|
||||||
ProjectDeviceSyncCommandResultDto,
|
ProjectDeviceSyncCommandResultDto,
|
||||||
ProjectDeviceSyncPreviewDto,
|
ProjectDeviceSyncPreviewDto,
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
@@ -33,6 +38,18 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
CircuitSnapshot,
|
CircuitSnapshot,
|
||||||
} from "../../domain/models/circuit-structure-project-command.model";
|
} from "../../domain/models/circuit-structure-project-command.model";
|
||||||
|
import type {
|
||||||
|
CircuitDeviceRowMoveAssignment,
|
||||||
|
} from "../../domain/models/circuit-device-row-move-project-command.model";
|
||||||
|
import type {
|
||||||
|
CircuitSectionReorderAssignment,
|
||||||
|
} from "../../domain/models/circuit-section-reorder-project-command.model";
|
||||||
|
import type {
|
||||||
|
CircuitSectionsReorderSection,
|
||||||
|
} from "../../domain/models/circuit-sections-reorder-project-command.model";
|
||||||
|
import type {
|
||||||
|
CircuitSectionRenumberAssignment,
|
||||||
|
} from "../../domain/models/circuit-section-renumber-project-command.model";
|
||||||
|
|
||||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -70,6 +87,62 @@ export function getProjectHistory(projectId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listProjectRevisions(
|
||||||
|
projectId: string,
|
||||||
|
input: { limit?: number; beforeRevision?: number } = {}
|
||||||
|
) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (input.limit !== undefined) {
|
||||||
|
query.set("limit", String(input.limit));
|
||||||
|
}
|
||||||
|
if (input.beforeRevision !== undefined) {
|
||||||
|
query.set("beforeRevision", String(input.beforeRevision));
|
||||||
|
}
|
||||||
|
const suffix = query.size ? `?${query.toString()}` : "";
|
||||||
|
return request<ProjectRevisionPageDto>(
|
||||||
|
`/api/projects/${projectId}/history/revisions${suffix}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listProjectSnapshots(projectId: string) {
|
||||||
|
return request<ProjectSnapshotMetadataDto[]>(
|
||||||
|
`/api/projects/${projectId}/snapshots`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNamedProjectSnapshot(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
name: string,
|
||||||
|
description?: string
|
||||||
|
) {
|
||||||
|
return request<ProjectSnapshotMetadataDto>(
|
||||||
|
`/api/projects/${projectId}/snapshots`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expectedRevision,
|
||||||
|
name,
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreProjectSnapshot(
|
||||||
|
projectId: string,
|
||||||
|
snapshotId: string,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return request<ProjectSnapshotRestoreResultDto>(
|
||||||
|
`/api/projects/${projectId}/snapshots/${snapshotId}/restore`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ expectedRevision }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function executeProjectCommand(
|
export function executeProjectCommand(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
expectedRevision: number,
|
expectedRevision: number,
|
||||||
@@ -134,23 +207,34 @@ export function createProject(name: string) {
|
|||||||
|
|
||||||
export function updateProjectSettings(
|
export function updateProjectSettings(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
input: { singlePhaseVoltageV: number; threePhaseVoltageV: number }
|
input: { singlePhaseVoltageV: number; threePhaseVoltageV: number }
|
||||||
) {
|
) {
|
||||||
return request<ProjectDto>(`/api/projects/${projectId}`, {
|
return request<ProjectSettingsCommandResultDto>(
|
||||||
|
`/api/projects/${projectId}`,
|
||||||
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify({ expectedRevision, ...input }),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listDistributionBoards(projectId: string) {
|
export function listDistributionBoards(projectId: string) {
|
||||||
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
|
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDistributionBoard(projectId: string, name: string) {
|
export function createDistributionBoard(
|
||||||
return request<DistributionBoardDto>(`/api/projects/${projectId}/distribution-boards`, {
|
projectId: string,
|
||||||
method: "POST",
|
name: string,
|
||||||
body: JSON.stringify({ name }),
|
expectedRevision: number
|
||||||
});
|
) {
|
||||||
|
return request<DistributionBoardCommandResultDto>(
|
||||||
|
`/api/projects/${projectId}/distribution-boards`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, expectedRevision }),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCircuitLists(projectId: string) {
|
export function listCircuitLists(projectId: string) {
|
||||||
@@ -212,12 +296,6 @@ export function deleteCircuitCommand(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteCircuitById(circuitId: string) {
|
|
||||||
return request<void>(`/api/circuits/${circuitId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNextCircuitIdentifier(sectionId: string) {
|
export function getNextCircuitIdentifier(sectionId: string) {
|
||||||
return request<{ sectionId: string; nextIdentifier: string }>(
|
return request<{ sectionId: string; nextIdentifier: string }>(
|
||||||
`/api/circuit-sections/${sectionId}/next-identifier`
|
`/api/circuit-sections/${sectionId}/next-identifier`
|
||||||
@@ -275,52 +353,101 @@ export function deleteCircuitDeviceRowCommand(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveCircuitDeviceRowById(
|
export function moveCircuitDeviceRowsCommand(
|
||||||
rowId: string,
|
projectId: string,
|
||||||
input: { targetCircuitId?: string; targetSectionId?: string; createNewCircuit?: boolean }
|
expectedRevision: number,
|
||||||
|
moves: CircuitDeviceRowMoveAssignment[],
|
||||||
|
description: string
|
||||||
) {
|
) {
|
||||||
return request(`/api/circuit-device-rows/${rowId}/move`, {
|
return executeProjectCommand(
|
||||||
method: "PATCH",
|
projectId,
|
||||||
body: JSON.stringify(input),
|
expectedRevision,
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveCircuitDeviceRowsBulk(input: {
|
|
||||||
rowIds: string[];
|
|
||||||
targetCircuitId?: string;
|
|
||||||
targetSectionId?: string;
|
|
||||||
createNewCircuit?: boolean;
|
|
||||||
}) {
|
|
||||||
return request<{ movedRowIds: string[]; targetCircuitId: string; createdCircuitId?: string }>(
|
|
||||||
"/api/circuit-device-rows/move-bulk",
|
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
schemaVersion: 1,
|
||||||
body: JSON.stringify(input),
|
type: "circuit-device-row.move",
|
||||||
}
|
payload: { moves },
|
||||||
|
},
|
||||||
|
description
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renumberCircuitSection(sectionId: string) {
|
export function moveCircuitDeviceRowsToNewCircuitCommand(
|
||||||
return request(`/api/circuit-sections/${sectionId}/renumber`, {
|
projectId: string,
|
||||||
method: "POST",
|
expectedRevision: number,
|
||||||
});
|
targetCircuit: CircuitSnapshot,
|
||||||
}
|
moves: CircuitDeviceRowMoveAssignment[],
|
||||||
|
description: string
|
||||||
export function reorderSectionCircuits(sectionId: string, orderedCircuitIds: string[]) {
|
|
||||||
return request(`/api/circuit-sections/${sectionId}/circuits/reorder`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({ orderedCircuitIds }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSectionEquipmentIdentifiers(
|
|
||||||
sectionId: string,
|
|
||||||
identifiers: Array<{ circuitId: string; equipmentIdentifier: string }>
|
|
||||||
) {
|
) {
|
||||||
return request(`/api/circuit-sections/${sectionId}/equipment-identifiers`, {
|
return executeProjectCommand(
|
||||||
method: "PATCH",
|
projectId,
|
||||||
body: JSON.stringify({ identifiers }),
|
expectedRevision,
|
||||||
});
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit-device-row.move-with-new-circuit",
|
||||||
|
payload: {
|
||||||
|
targetCircuitAction: "create",
|
||||||
|
targetCircuit,
|
||||||
|
moves,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reorderCircuitSectionCommand(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
sectionId: string,
|
||||||
|
assignments: CircuitSectionReorderAssignment[],
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
return executeProjectCommand(
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.reorder-section",
|
||||||
|
payload: { sectionId, assignments },
|
||||||
|
},
|
||||||
|
description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reorderCircuitSectionsCommand(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
sections: CircuitSectionsReorderSection[],
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
return executeProjectCommand(
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.reorder-sections",
|
||||||
|
payload: { sections },
|
||||||
|
},
|
||||||
|
description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renumberCircuitSectionCommand(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
sectionId: string,
|
||||||
|
assignments: CircuitSectionRenumberAssignment[],
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
return executeProjectCommand(
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.renumber-section",
|
||||||
|
payload: { sectionId, assignments },
|
||||||
|
},
|
||||||
|
description
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFloors(projectId: string) {
|
export function listFloors(projectId: string) {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type {
|
||||||
|
CircuitTreeCircuitDto,
|
||||||
|
CircuitTreeDeviceRowDto,
|
||||||
|
} from "../types.js";
|
||||||
|
import type {
|
||||||
|
CircuitDeviceRowMoveAssignment,
|
||||||
|
} from "../../domain/models/circuit-device-row-move-project-command.model.js";
|
||||||
|
|
||||||
|
export function buildCircuitDeviceRowMoveAssignments(input: {
|
||||||
|
circuits: CircuitTreeCircuitDto[];
|
||||||
|
rowIds: string[];
|
||||||
|
targetCircuitId: string;
|
||||||
|
targetDeviceRows: CircuitTreeDeviceRowDto[];
|
||||||
|
}): CircuitDeviceRowMoveAssignment[] {
|
||||||
|
const { circuits, rowIds, targetCircuitId, targetDeviceRows } =
|
||||||
|
input;
|
||||||
|
if (new Set(rowIds).size !== rowIds.length) {
|
||||||
|
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceByRowId = new Map<
|
||||||
|
string,
|
||||||
|
{ circuitId: string; sortOrder: number }
|
||||||
|
>();
|
||||||
|
for (const circuit of circuits) {
|
||||||
|
for (const row of circuit.deviceRows) {
|
||||||
|
sourceByRowId.set(row.id, {
|
||||||
|
circuitId: circuit.id,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedRowIds = new Set(rowIds);
|
||||||
|
const retainedTargetRows = targetDeviceRows.filter(
|
||||||
|
(row) => !movedRowIds.has(row.id)
|
||||||
|
);
|
||||||
|
const lastTargetSortOrder = retainedTargetRows.reduce(
|
||||||
|
(highest, row) => Math.max(highest, row.sortOrder),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return rowIds.flatMap((rowId, index) => {
|
||||||
|
const source = sourceByRowId.get(rowId);
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("Eine ausgewählte Gerätezeile wurde nicht gefunden.");
|
||||||
|
}
|
||||||
|
const targetSortOrder =
|
||||||
|
lastTargetSortOrder + (index + 1) * 10;
|
||||||
|
if (
|
||||||
|
source.circuitId === targetCircuitId &&
|
||||||
|
source.sortOrder === targetSortOrder
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
rowId,
|
||||||
|
expectedCircuitId: source.circuitId,
|
||||||
|
expectedSortOrder: source.sortOrder,
|
||||||
|
targetCircuitId,
|
||||||
|
targetSortOrder,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type {
|
||||||
|
CircuitTreeResponseDto,
|
||||||
|
ProjectHistoryStateDto,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export interface CircuitEditorSnapshot {
|
||||||
|
tree: CircuitTreeResponseDto;
|
||||||
|
history: ProjectHistoryStateDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCircuitEditorSnapshot(
|
||||||
|
readTree: () => Promise<CircuitTreeResponseDto>,
|
||||||
|
readHistory: () => Promise<ProjectHistoryStateDto>,
|
||||||
|
maxAttempts = 3
|
||||||
|
): Promise<CircuitEditorSnapshot> {
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
|
const tree = await readTree();
|
||||||
|
const history = await readHistory();
|
||||||
|
if (tree.currentRevision === history.currentRevision) {
|
||||||
|
return { tree, history };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"Der Projektstand hat sich während des Ladens geändert. Bitte erneut versuchen."
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type {
|
||||||
|
CircuitSectionRenumberAssignment,
|
||||||
|
} from "../../domain/models/circuit-section-renumber-project-command.model.js";
|
||||||
|
import type { CircuitTreeSectionDto } from "../types.js";
|
||||||
|
|
||||||
|
export function buildCircuitSectionRenumberAssignments(
|
||||||
|
sections: CircuitTreeSectionDto[],
|
||||||
|
sectionId: string
|
||||||
|
): CircuitSectionRenumberAssignment[] {
|
||||||
|
const targetSection = sections.find(
|
||||||
|
(section) => section.id === sectionId
|
||||||
|
);
|
||||||
|
if (!targetSection) {
|
||||||
|
throw new Error("Der Bereich wurde nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifiersOutsideSection = new Set(
|
||||||
|
sections
|
||||||
|
.filter((section) => section.id !== sectionId)
|
||||||
|
.flatMap((section) =>
|
||||||
|
section.circuits.map(
|
||||||
|
(circuit) => circuit.equipmentIdentifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const assignments: CircuitSectionRenumberAssignment[] = [];
|
||||||
|
let suffix = 1;
|
||||||
|
for (const circuit of targetSection.circuits) {
|
||||||
|
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
|
||||||
|
while (
|
||||||
|
identifiersOutsideSection.has(targetEquipmentIdentifier)
|
||||||
|
) {
|
||||||
|
suffix += 1;
|
||||||
|
targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
|
||||||
|
}
|
||||||
|
assignments.push({
|
||||||
|
circuitId: circuit.id,
|
||||||
|
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
|
||||||
|
targetEquipmentIdentifier,
|
||||||
|
});
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
assignments.every(
|
||||||
|
(assignment) =>
|
||||||
|
assignment.expectedEquipmentIdentifier ===
|
||||||
|
assignment.targetEquipmentIdentifier
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type {
|
||||||
|
CircuitSectionReorderAssignment,
|
||||||
|
} from "../../domain/models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import type { CircuitTreeCircuitDto } from "../types.js";
|
||||||
|
|
||||||
|
export function buildCircuitSectionReorderAssignments(
|
||||||
|
circuits: CircuitTreeCircuitDto[],
|
||||||
|
orderedCircuitIds: string[]
|
||||||
|
): CircuitSectionReorderAssignment[] {
|
||||||
|
if (
|
||||||
|
circuits.length !== orderedCircuitIds.length ||
|
||||||
|
new Set(orderedCircuitIds).size !== orderedCircuitIds.length
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Die Reihenfolge muss jeden Stromkreis des Bereichs genau einmal enthalten."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const circuitsById = new Map(
|
||||||
|
circuits.map((circuit) => [circuit.id, circuit])
|
||||||
|
);
|
||||||
|
const assignments = orderedCircuitIds.map((circuitId, index) => {
|
||||||
|
const circuit = circuitsById.get(circuitId);
|
||||||
|
if (!circuit) {
|
||||||
|
throw new Error(
|
||||||
|
"Die Reihenfolge enthält einen ungültigen Stromkreis."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
circuitId,
|
||||||
|
expectedSortOrder: circuit.sortOrder,
|
||||||
|
targetSortOrder: (index + 1) * 10,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
assignments.every(
|
||||||
|
(assignment) =>
|
||||||
|
assignment.expectedSortOrder === assignment.targetSortOrder
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type {
|
||||||
|
ProjectRevisionSourceDto,
|
||||||
|
ProjectRevisionSummaryDto,
|
||||||
|
ProjectSnapshotMetadataDto,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const sourceLabels: Record<ProjectRevisionSourceDto, string> = {
|
||||||
|
user: "Bearbeitung",
|
||||||
|
undo: "Rückgängig",
|
||||||
|
redo: "Wiederholt",
|
||||||
|
restore: "Wiederherstellung",
|
||||||
|
migration: "Migration",
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandTypeLabels: Record<string, string> = {
|
||||||
|
"circuit.update": "Stromkreis bearbeitet",
|
||||||
|
"circuit.insert": "Stromkreis angelegt",
|
||||||
|
"circuit.delete": "Stromkreis gelöscht",
|
||||||
|
"circuit-device-row.update": "Gerätezeile bearbeitet",
|
||||||
|
"circuit-device-row.insert": "Gerätezeile angelegt",
|
||||||
|
"circuit-device-row.delete": "Gerätezeile gelöscht",
|
||||||
|
"circuit-device-row.move": "Gerätezeile verschoben",
|
||||||
|
"circuit-device-row.move-with-new-circuit":
|
||||||
|
"Gerätezeile in neuen Stromkreis verschoben",
|
||||||
|
"circuit.reorder-section": "Stromkreise sortiert",
|
||||||
|
"circuit.reorder-sections": "Stromkreise bereichsübergreifend sortiert",
|
||||||
|
"circuit.renumber-section": "Bereich neu nummeriert",
|
||||||
|
"project-device.update": "Projektgerät bearbeitet",
|
||||||
|
"project-device.insert": "Projektgerät angelegt",
|
||||||
|
"project-device.delete": "Projektgerät gelöscht",
|
||||||
|
"project-device.sync-rows": "Projektgerät-Verknüpfungen geändert",
|
||||||
|
"project.update-settings": "Projekteinstellungen bearbeitet",
|
||||||
|
"distribution-board.insert": "Verteilung angelegt",
|
||||||
|
"distribution-board.delete": "Verteilung entfernt",
|
||||||
|
"project.restore-state": "Projektstand wiederhergestellt",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getProjectRevisionSourceLabel(
|
||||||
|
source: ProjectRevisionSourceDto
|
||||||
|
) {
|
||||||
|
return sourceLabels[source];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectRevisionDescription(
|
||||||
|
revision: ProjectRevisionSummaryDto
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
revision.description?.trim() ||
|
||||||
|
commandTypeLabels[revision.commandType] ||
|
||||||
|
revision.commandType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectSnapshotKindLabel(
|
||||||
|
kind: ProjectSnapshotMetadataDto["kind"]
|
||||||
|
) {
|
||||||
|
return kind === "automatic" ? "Automatisch" : "Benannt";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeProjectRevisionPages(
|
||||||
|
current: ProjectRevisionSummaryDto[],
|
||||||
|
next: ProjectRevisionSummaryDto[]
|
||||||
|
) {
|
||||||
|
const byId = new Map(
|
||||||
|
[...current, ...next].map((revision) => [
|
||||||
|
revision.revisionId,
|
||||||
|
revision,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return [...byId.values()].sort(
|
||||||
|
(left, right) => right.revisionNumber - left.revisionNumber
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { CircuitNumberingService } from "../../domain/services/circuit-numbering.service.js";
|
||||||
|
|
||||||
|
export const circuitNumberingService = new CircuitNumberingService();
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
|
||||||
import { db } from "../../db/client.js";
|
|
||||||
import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.js";
|
|
||||||
import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js";
|
|
||||||
|
|
||||||
export const circuitWriteService = new CircuitWriteService({
|
|
||||||
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
|
|
||||||
circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db),
|
|
||||||
});
|
|
||||||
@@ -6,10 +6,13 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p
|
|||||||
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
|
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||||
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
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 { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
import { ProjectHistoryRepository } from "../../db/repositories/project-history.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";
|
||||||
|
import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js";
|
||||||
|
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
|
||||||
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||||
|
|
||||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||||
@@ -21,6 +24,8 @@ export const circuitDeviceRowMoveProjectCommandStore =
|
|||||||
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||||
export const circuitStructureProjectCommandStore =
|
export const circuitStructureProjectCommandStore =
|
||||||
new CircuitStructureProjectCommandRepository(db);
|
new CircuitStructureProjectCommandRepository(db);
|
||||||
|
export const distributionBoardStructureProjectCommandStore =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(db);
|
||||||
export const circuitSectionReorderProjectCommandStore =
|
export const circuitSectionReorderProjectCommandStore =
|
||||||
new CircuitSectionReorderProjectCommandRepository(db);
|
new CircuitSectionReorderProjectCommandRepository(db);
|
||||||
export const circuitSectionRenumberProjectCommandStore =
|
export const circuitSectionRenumberProjectCommandStore =
|
||||||
@@ -31,6 +36,10 @@ export const projectDeviceProjectCommandStore =
|
|||||||
new ProjectDeviceProjectCommandRepository(db);
|
new ProjectDeviceProjectCommandRepository(db);
|
||||||
export const projectDeviceRowSyncProjectCommandStore =
|
export const projectDeviceRowSyncProjectCommandStore =
|
||||||
new ProjectDeviceRowSyncProjectCommandRepository(db);
|
new ProjectDeviceRowSyncProjectCommandRepository(db);
|
||||||
|
export const projectSettingsProjectCommandStore =
|
||||||
|
new ProjectSettingsProjectCommandRepository(db);
|
||||||
|
export const projectStateRestoreCommandStore =
|
||||||
|
new ProjectStateRestoreCommandRepository(db);
|
||||||
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||||
export const projectCommandService = new ProjectCommandService(
|
export const projectCommandService = new ProjectCommandService(
|
||||||
circuitProjectCommandStore,
|
circuitProjectCommandStore,
|
||||||
@@ -38,10 +47,13 @@ export const projectCommandService = new ProjectCommandService(
|
|||||||
circuitDeviceRowStructureProjectCommandStore,
|
circuitDeviceRowStructureProjectCommandStore,
|
||||||
circuitDeviceRowMoveProjectCommandStore,
|
circuitDeviceRowMoveProjectCommandStore,
|
||||||
circuitStructureProjectCommandStore,
|
circuitStructureProjectCommandStore,
|
||||||
|
distributionBoardStructureProjectCommandStore,
|
||||||
circuitSectionReorderProjectCommandStore,
|
circuitSectionReorderProjectCommandStore,
|
||||||
circuitSectionRenumberProjectCommandStore,
|
circuitSectionRenumberProjectCommandStore,
|
||||||
projectDeviceStructureProjectCommandStore,
|
projectDeviceStructureProjectCommandStore,
|
||||||
projectDeviceProjectCommandStore,
|
projectDeviceProjectCommandStore,
|
||||||
projectDeviceRowSyncProjectCommandStore,
|
projectDeviceRowSyncProjectCommandStore,
|
||||||
|
projectSettingsProjectCommandStore,
|
||||||
|
projectStateRestoreCommandStore,
|
||||||
projectHistoryStore
|
projectHistoryStore
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { db } from "../../db/client.js";
|
||||||
|
import { ProjectSnapshotRepository } from "../../db/repositories/project-snapshot.repository.js";
|
||||||
|
|
||||||
|
export const projectSnapshotStore =
|
||||||
|
new ProjectSnapshotRepository(db);
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
|
||||||
import {
|
|
||||||
moveCircuitDeviceRowsBulkSchema,
|
|
||||||
moveCircuitDeviceRowSchema,
|
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
|
||||||
|
|
||||||
export async function moveCircuitDeviceRow(req: Request, res: Response) {
|
|
||||||
const { rowId } = req.params;
|
|
||||||
if (typeof rowId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid rowId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = moveCircuitDeviceRowSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const moved = await circuitWriteService.moveDeviceRow(rowId, parsed.data);
|
|
||||||
return res.json(moved);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function moveCircuitDeviceRowsBulk(req: Request, res: Response) {
|
|
||||||
const parsed = moveCircuitDeviceRowsBulkSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const moved = await circuitWriteService.moveDeviceRowsBulk(parsed.data);
|
|
||||||
return res.json(moved);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device rows." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
|
||||||
import {
|
|
||||||
reorderSectionCircuitsSchema,
|
|
||||||
updateSectionEquipmentIdentifiersSchema,
|
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
|
||||||
|
|
||||||
export async function renumberCircuitSection(req: Request, res: Response) {
|
|
||||||
const { sectionId } = req.params;
|
|
||||||
if (typeof sectionId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid sectionId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedCircuits = await circuitWriteService.renumberSection(sectionId);
|
|
||||||
return res.json({ sectionId, circuits: updatedCircuits });
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to renumber section." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reorderSectionCircuits(req: Request, res: Response) {
|
|
||||||
const { sectionId } = req.params;
|
|
||||||
if (typeof sectionId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid sectionId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = reorderSectionCircuitsSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedCircuits = await circuitWriteService.reorderCircuitsInSection(sectionId, parsed.data);
|
|
||||||
return res.json({ sectionId, circuits: updatedCircuits });
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reorder circuits." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSectionEquipmentIdentifiers(req: Request, res: Response) {
|
|
||||||
const { sectionId } = req.params;
|
|
||||||
if (typeof sectionId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid sectionId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = updateSectionEquipmentIdentifiersSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedCircuits = await circuitWriteService.updateSectionEquipmentIdentifiers(sectionId, parsed.data);
|
|
||||||
return res.json({ sectionId, circuits: updatedCircuits });
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update section identifiers." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,5 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
|
||||||
|
|
||||||
export async function deleteCircuit(req: Request, res: Response) {
|
|
||||||
const { circuitId } = req.params;
|
|
||||||
if (typeof circuitId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid circuitId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await circuitWriteService.deleteCircuit(circuitId);
|
|
||||||
return res.status(204).send();
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete circuit." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getNextCircuitIdentifier(req: Request, res: Response) {
|
export async function getNextCircuitIdentifier(req: Request, res: Response) {
|
||||||
const { sectionId } = req.params;
|
const { sectionId } = req.params;
|
||||||
@@ -21,7 +7,8 @@ export async function getNextCircuitIdentifier(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: "Invalid sectionId" });
|
return res.status(400).json({ error: "Invalid sectionId" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const nextIdentifier = await circuitWriteService.getNextIdentifier(sectionId);
|
const nextIdentifier =
|
||||||
|
await circuitNumberingService.getNextIdentifier(sectionId);
|
||||||
return res.json({ sectionId, nextIdentifier });
|
return res.json({ sectionId, nextIdentifier });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to get identifier." });
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to get identifier." });
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { db } from "../../db/client.js";
|
import { db } from "../../db/client.js";
|
||||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||||
|
import {
|
||||||
|
createDistributionBoardInsertProjectCommand,
|
||||||
|
createDistributionBoardStructureSnapshot,
|
||||||
|
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||||
import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
|
import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||||
|
|
||||||
const distributionBoardRepository = new DistributionBoardRepository(db);
|
const distributionBoardRepository = new DistributionBoardRepository(db);
|
||||||
|
|
||||||
@@ -26,9 +32,23 @@ export async function createDistributionBoard(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const board = distributionBoardRepository.createWithCircuitListAndDefaultSections(
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
projectId,
|
projectId,
|
||||||
parsed.data.name
|
parsed.data.name
|
||||||
);
|
);
|
||||||
return res.status(201).json(board);
|
try {
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Verteilung anlegen",
|
||||||
|
command:
|
||||||
|
createDistributionBoardInsertProjectCommand(structure),
|
||||||
|
});
|
||||||
|
return res.status(201).json({
|
||||||
|
...result,
|
||||||
|
distributionBoard: structure.distributionBoard,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
import { listProjectRevisionsQuerySchema } from "../../shared/validation/project-history.schemas.js";
|
||||||
import { projectHistoryStore } from "../composition/project-command-stores.js";
|
import { projectHistoryStore } from "../composition/project-command-stores.js";
|
||||||
|
|
||||||
export function getProjectHistory(req: Request, res: Response) {
|
export function getProjectHistory(req: Request, res: Response) {
|
||||||
@@ -13,3 +14,23 @@ export function getProjectHistory(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
return res.json(state);
|
return res.json(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listProjectRevisions(req: Request, res: Response) {
|
||||||
|
const { projectId } = req.params;
|
||||||
|
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||||
|
return res.status(400).json({ error: "Invalid projectId" });
|
||||||
|
}
|
||||||
|
const parsed = listProjectRevisionsQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = projectHistoryStore.listRevisions({
|
||||||
|
projectId,
|
||||||
|
...parsed.data,
|
||||||
|
});
|
||||||
|
if (!page) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
return res.json(page);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
|
||||||
|
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
|
||||||
|
import {
|
||||||
|
createNamedProjectSnapshotSchema,
|
||||||
|
restoreProjectSnapshotSchema,
|
||||||
|
} from "../../shared/validation/project-snapshot.schemas.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
|
||||||
|
|
||||||
|
export function listProjectSnapshots(req: Request, res: Response) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snapshots = projectSnapshotStore.listByProject(projectId);
|
||||||
|
if (!snapshots) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
return res.json(snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNamedProjectSnapshot(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = createNamedProjectSnapshotSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = projectSnapshotStore.createNamed({
|
||||||
|
projectId,
|
||||||
|
...parsed.data,
|
||||||
|
});
|
||||||
|
if (!snapshot) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
return res.status(201).json(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ProjectRevisionConflictError) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: error.message,
|
||||||
|
code: "PROJECT_REVISION_CONFLICT",
|
||||||
|
expectedRevision: error.expectedRevision,
|
||||||
|
currentRevision: error.actualRevision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error instanceof ProjectSnapshotNameConflictError) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: error.message,
|
||||||
|
code: "PROJECT_SNAPSHOT_NAME_CONFLICT",
|
||||||
|
snapshotName: error.snapshotName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreProjectSnapshot(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { snapshotId } = req.params;
|
||||||
|
if (typeof snapshotId !== "string" || !snapshotId.trim()) {
|
||||||
|
return res.status(400).json({ error: "Invalid snapshotId" });
|
||||||
|
}
|
||||||
|
const parsed = restoreProjectSnapshotSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prepared = projectSnapshotStore.prepareRestore(
|
||||||
|
projectId,
|
||||||
|
snapshotId
|
||||||
|
);
|
||||||
|
if (!prepared) {
|
||||||
|
return res.status(404).json({ error: "Project snapshot not found" });
|
||||||
|
}
|
||||||
|
const result = projectCommandService.executeRestore({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: `Snapshot "${prepared.snapshot.name}" wiederherstellen`,
|
||||||
|
command: prepared.command,
|
||||||
|
});
|
||||||
|
return res.json({
|
||||||
|
snapshot: prepared.snapshot,
|
||||||
|
revision: result.revision,
|
||||||
|
history: result.history,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ProjectRevisionConflictError) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: error.message,
|
||||||
|
code: "PROJECT_REVISION_CONFLICT",
|
||||||
|
expectedRevision: error.expectedRevision,
|
||||||
|
currentRevision: error.actualRevision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error instanceof ProjectStateConflictError) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: error.message,
|
||||||
|
code: "PROJECT_STATE_CONFLICT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectId(req: Request, res: Response) {
|
||||||
|
const { projectId } = req.params;
|
||||||
|
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||||
|
res.status(400).json({ error: "Invalid projectId" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
createProjectSchema,
|
createProjectSchema,
|
||||||
updateProjectSettingsSchema,
|
updateProjectSettingsSchema,
|
||||||
} from "../../shared/validation/project-structure.schemas.js";
|
} from "../../shared/validation/project-structure.schemas.js";
|
||||||
|
import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||||
|
|
||||||
const projectRepository = new ProjectRepository();
|
const projectRepository = new ProjectRepository();
|
||||||
|
|
||||||
@@ -44,10 +47,20 @@ export async function updateProjectSettings(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
|
|
||||||
await projectRepository.updateSettings(projectId, parsed.data);
|
const { expectedRevision, ...settings } = parsed.data;
|
||||||
const row = await projectRepository.findById(projectId);
|
try {
|
||||||
if (!row) {
|
const result = projectCommandService.executeUser({
|
||||||
return res.status(404).json({ error: "Project not found" });
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
description: "Projekteinstellungen bearbeiten",
|
||||||
|
command: createProjectSettingsUpdateProjectCommand(settings),
|
||||||
|
});
|
||||||
|
const project = await projectRepository.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Updated project could not be loaded.");
|
||||||
|
}
|
||||||
|
return res.json({ ...result, project });
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
}
|
}
|
||||||
return res.json(row);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { circuitDeviceRowRouter } from "./routes/circuit-device-row.routes.js";
|
|
||||||
import { circuitRouter } from "./routes/circuit.routes.js";
|
import { circuitRouter } from "./routes/circuit.routes.js";
|
||||||
import { circuitSectionRouter } from "./routes/circuit-section.routes.js";
|
|
||||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||||
import { projectRouter } from "./routes/project.routes.js";
|
import { projectRouter } from "./routes/project.routes.js";
|
||||||
@@ -18,8 +16,6 @@ app.get("/health", (_req, res) => {
|
|||||||
|
|
||||||
app.use("/api/projects", projectRouter);
|
app.use("/api/projects", projectRouter);
|
||||||
app.use("/api", circuitRouter);
|
app.use("/api", circuitRouter);
|
||||||
app.use("/api", circuitDeviceRowRouter);
|
|
||||||
app.use("/api", circuitSectionRouter);
|
|
||||||
app.use("/api/global-devices", globalDeviceRouter);
|
app.use("/api/global-devices", globalDeviceRouter);
|
||||||
app.use("/api/project-devices", projectDeviceRouter);
|
app.use("/api/project-devices", projectDeviceRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
import {
|
|
||||||
moveCircuitDeviceRowsBulk,
|
|
||||||
moveCircuitDeviceRow,
|
|
||||||
} from "../controllers/circuit-device-row.controller.js";
|
|
||||||
|
|
||||||
export const circuitDeviceRowRouter = Router();
|
|
||||||
|
|
||||||
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
|
|
||||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
import {
|
|
||||||
renumberCircuitSection,
|
|
||||||
reorderSectionCircuits,
|
|
||||||
updateSectionEquipmentIdentifiers,
|
|
||||||
} from "../controllers/circuit-section.controller.js";
|
|
||||||
|
|
||||||
export const circuitSectionRouter = Router();
|
|
||||||
|
|
||||||
circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection);
|
|
||||||
circuitSectionRouter.patch("/circuit-sections/:sectionId/circuits/reorder", reorderSectionCircuits);
|
|
||||||
circuitSectionRouter.patch("/circuit-sections/:sectionId/equipment-identifiers", updateSectionEquipmentIdentifiers);
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
deleteCircuit,
|
|
||||||
getNextCircuitIdentifier,
|
getNextCircuitIdentifier,
|
||||||
} from "../controllers/circuit.controller.js";
|
} from "../controllers/circuit.controller.js";
|
||||||
|
|
||||||
export const circuitRouter = Router();
|
export const circuitRouter = Router();
|
||||||
|
|
||||||
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
|
|
||||||
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle
|
|||||||
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
|
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
|
||||||
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
|
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
|
||||||
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
|
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
|
||||||
import { getProjectHistory } from "../controllers/project-history.controller.js";
|
import {
|
||||||
|
getProjectHistory,
|
||||||
|
listProjectRevisions,
|
||||||
|
} from "../controllers/project-history.controller.js";
|
||||||
import {
|
import {
|
||||||
executeProjectCommand,
|
executeProjectCommand,
|
||||||
redoProjectCommand,
|
redoProjectCommand,
|
||||||
undoProjectCommand,
|
undoProjectCommand,
|
||||||
} from "../controllers/project-command.controller.js";
|
} from "../controllers/project-command.controller.js";
|
||||||
|
import {
|
||||||
|
createNamedProjectSnapshot,
|
||||||
|
listProjectSnapshots,
|
||||||
|
restoreProjectSnapshot,
|
||||||
|
} from "../controllers/project-snapshot.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
@@ -26,9 +34,16 @@ projectRouter.get("/", listProjects);
|
|||||||
projectRouter.post("/", createProject);
|
projectRouter.post("/", createProject);
|
||||||
projectRouter.get("/:projectId", getProject);
|
projectRouter.get("/:projectId", getProject);
|
||||||
projectRouter.get("/:projectId/history", getProjectHistory);
|
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||||
|
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
||||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||||
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
|
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
|
||||||
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
|
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
|
||||||
|
projectRouter.get("/:projectId/snapshots", listProjectSnapshots);
|
||||||
|
projectRouter.post("/:projectId/snapshots", createNamedProjectSnapshot);
|
||||||
|
projectRouter.post(
|
||||||
|
"/:projectId/snapshots/:snapshotId/restore",
|
||||||
|
restoreProjectSnapshot
|
||||||
|
);
|
||||||
projectRouter.put("/:projectId", updateProjectSettings);
|
projectRouter.put("/:projectId", updateProjectSettings);
|
||||||
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||||
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const moveCircuitDeviceRowSchema = z
|
|
||||||
.object({
|
|
||||||
targetCircuitId: z.string().min(1).optional(),
|
|
||||||
targetSectionId: z.string().min(1).optional(),
|
|
||||||
createNewCircuit: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(value) =>
|
|
||||||
Boolean(value.targetCircuitId) ||
|
|
||||||
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
|
|
||||||
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
|
||||||
);
|
|
||||||
|
|
||||||
export const moveCircuitDeviceRowsBulkSchema = z
|
|
||||||
.object({
|
|
||||||
rowIds: z.array(z.string().min(1)).min(1),
|
|
||||||
targetCircuitId: z.string().min(1).optional(),
|
|
||||||
targetSectionId: z.string().min(1).optional(),
|
|
||||||
createNewCircuit: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(value) =>
|
|
||||||
Boolean(value.targetCircuitId) ||
|
|
||||||
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
|
|
||||||
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
|
||||||
);
|
|
||||||
|
|
||||||
export const reorderSectionCircuitsSchema = z.object({
|
|
||||||
orderedCircuitIds: z.array(z.string().min(1)).min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateSectionEquipmentIdentifiersSchema = z.object({
|
|
||||||
identifiers: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
circuitId: z.string().min(1),
|
|
||||||
equipmentIdentifier: z.string().min(1),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
|
||||||
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
|
|
||||||
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
|
||||||
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const listProjectRevisionsQuerySchema = z
|
||||||
|
.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(25),
|
||||||
|
beforeRevision: z.coerce.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||||
|
|
||||||
|
export const createNamedProjectSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
description: z.string().trim().max(500).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const restoreProjectSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||||
|
|
||||||
export const createProjectSchema = z.object({
|
export const createProjectSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -6,14 +7,20 @@ export const createProjectSchema = z.object({
|
|||||||
threePhaseVoltageV: z.number().positive().optional(),
|
threePhaseVoltageV: z.number().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateProjectSettingsSchema = z.object({
|
export const updateProjectSettingsSchema = z
|
||||||
singlePhaseVoltageV: z.number().positive(),
|
.object({
|
||||||
threePhaseVoltageV: z.number().positive(),
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
});
|
singlePhaseVoltageV: z.number().positive(),
|
||||||
|
threePhaseVoltageV: z.number().positive(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const createDistributionBoardSchema = z.object({
|
export const createDistributionBoardSchema = z
|
||||||
name: z.string().min(1),
|
.object({
|
||||||
});
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const createFloorSchema = z.object({
|
export const createFloorSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
buildCircuitDeviceRowMoveAssignments,
|
||||||
|
} from "../src/frontend/utils/circuit-device-row-move-command.js";
|
||||||
|
import type {
|
||||||
|
CircuitTreeCircuitDto,
|
||||||
|
CircuitTreeDeviceRowDto,
|
||||||
|
} from "../src/frontend/types.js";
|
||||||
|
|
||||||
|
function row(
|
||||||
|
id: string,
|
||||||
|
sortOrder: number
|
||||||
|
): CircuitTreeDeviceRowDto {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sortOrder,
|
||||||
|
name: id,
|
||||||
|
displayName: id,
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
rowTotalPower: 0.1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function circuit(
|
||||||
|
id: string,
|
||||||
|
deviceRows: CircuitTreeDeviceRowDto[]
|
||||||
|
): CircuitTreeCircuitDto {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier: id,
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: deviceRows.length === 0,
|
||||||
|
circuitTotalPower: 0,
|
||||||
|
deviceRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit device-row move command builder", () => {
|
||||||
|
it("records exact sources and appends rows in selection order", () => {
|
||||||
|
const targetRows = [row("target-row", 20)];
|
||||||
|
const circuits = [
|
||||||
|
circuit("source-1", [row("row-1", 30)]),
|
||||||
|
circuit("source-2", [row("row-2", 10)]),
|
||||||
|
circuit("target", targetRows),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
buildCircuitDeviceRowMoveAssignments({
|
||||||
|
circuits,
|
||||||
|
rowIds: ["row-2", "row-1"],
|
||||||
|
targetCircuitId: "target",
|
||||||
|
targetDeviceRows: targetRows,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-2",
|
||||||
|
expectedCircuitId: "source-2",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: "target",
|
||||||
|
targetSortOrder: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "source-1",
|
||||||
|
expectedSortOrder: 30,
|
||||||
|
targetCircuitId: "target",
|
||||||
|
targetSortOrder: 40,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a generated target circuit at sort order ten", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildCircuitDeviceRowMoveAssignments({
|
||||||
|
circuits: [circuit("source", [row("row-1", 40)])],
|
||||||
|
rowIds: ["row-1"],
|
||||||
|
targetCircuitId: "generated-target",
|
||||||
|
targetDeviceRows: [],
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "source",
|
||||||
|
expectedSortOrder: 40,
|
||||||
|
targetCircuitId: "generated-target",
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits unchanged assignments during a same-circuit reorder", () => {
|
||||||
|
const rows = [row("row-1", 10), row("row-2", 20)];
|
||||||
|
assert.deepEqual(
|
||||||
|
buildCircuitDeviceRowMoveAssignments({
|
||||||
|
circuits: [circuit("target", rows)],
|
||||||
|
rowIds: ["row-1", "row-2"],
|
||||||
|
targetCircuitId: "target",
|
||||||
|
targetDeviceRows: rows,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate or missing selected rows", () => {
|
||||||
|
const circuits = [circuit("source", [row("row-1", 10)])];
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
buildCircuitDeviceRowMoveAssignments({
|
||||||
|
circuits,
|
||||||
|
rowIds: ["row-1", "row-1"],
|
||||||
|
targetCircuitId: "target",
|
||||||
|
targetDeviceRows: [],
|
||||||
|
}),
|
||||||
|
/mehrfach/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
buildCircuitDeviceRowMoveAssignments({
|
||||||
|
circuits,
|
||||||
|
rowIds: ["missing"],
|
||||||
|
targetCircuitId: "target",
|
||||||
|
targetDeviceRows: [],
|
||||||
|
}),
|
||||||
|
/nicht gefunden/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
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 { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
|
|
||||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
|
||||||
import { projects } from "../src/db/schema/projects.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" }).run();
|
|
||||||
const board = new DistributionBoardRepository(
|
|
||||||
context.db
|
|
||||||
).createWithCircuitListAndDefaultSections(
|
|
||||||
"project-1",
|
|
||||||
"UV-01"
|
|
||||||
);
|
|
||||||
const [section] = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuitSections)
|
|
||||||
.where(eq(circuitSections.circuitListId, board.id))
|
|
||||||
.limit(1)
|
|
||||||
.all();
|
|
||||||
context.db
|
|
||||||
.insert(circuits)
|
|
||||||
.values({
|
|
||||||
id: "circuit-1",
|
|
||||||
circuitListId: board.id,
|
|
||||||
sectionId: section.id,
|
|
||||||
equipmentIdentifier: "-1F1",
|
|
||||||
displayName: "Reserve",
|
|
||||||
sortOrder: 10,
|
|
||||||
isReserve: 1,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertCircuit(
|
|
||||||
context: DatabaseContext,
|
|
||||||
input: {
|
|
||||||
id: string;
|
|
||||||
equipmentIdentifier: string;
|
|
||||||
sortOrder: number;
|
|
||||||
isReserve?: number;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
|
|
||||||
context.db
|
|
||||||
.insert(circuits)
|
|
||||||
.values({
|
|
||||||
id: input.id,
|
|
||||||
circuitListId: seedCircuit.circuitListId,
|
|
||||||
sectionId: seedCircuit.sectionId,
|
|
||||||
equipmentIdentifier: input.equipmentIdentifier,
|
|
||||||
displayName: input.equipmentIdentifier,
|
|
||||||
sortOrder: input.sortOrder,
|
|
||||||
isReserve: input.isReserve ?? 1,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertDeviceRow(
|
|
||||||
context: DatabaseContext,
|
|
||||||
input: {
|
|
||||||
id?: string;
|
|
||||||
circuitId?: string;
|
|
||||||
sortOrder?: number;
|
|
||||||
displayName?: string;
|
|
||||||
} = {}
|
|
||||||
) {
|
|
||||||
const rowId = input.id ?? "row-1";
|
|
||||||
const circuitId = input.circuitId ?? "circuit-1";
|
|
||||||
context.db
|
|
||||||
.insert(circuitDeviceRows)
|
|
||||||
.values({
|
|
||||||
id: rowId,
|
|
||||||
circuitId,
|
|
||||||
sortOrder: input.sortOrder ?? 10,
|
|
||||||
name: "Leuchte",
|
|
||||||
displayName: input.displayName ?? "Leuchte",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
context.db
|
|
||||||
.update(circuits)
|
|
||||||
.set({ isReserve: 0 })
|
|
||||||
.where(eq(circuits.id, circuitId))
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("circuit device-row transaction repository", () => {
|
|
||||||
it("commits a new device row and clears the circuit reserve status together", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
|
||||||
const rowId = repository.createInCircuit({
|
|
||||||
circuitId: "circuit-1",
|
|
||||||
name: "Steckdose",
|
|
||||||
displayName: "Steckdose",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.2,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [row] = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(eq(circuitDeviceRows.id, rowId))
|
|
||||||
.all();
|
|
||||||
const [circuit] = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.id, "circuit-1"))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
assert.equal(row.circuitId, "circuit-1");
|
|
||||||
assert.equal(row.sortOrder, 10);
|
|
||||||
assert.equal(circuit.isReserve, 0);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back the row insert when clearing the reserve status fails", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_reserve_clear
|
|
||||||
BEFORE UPDATE OF is_reserve ON circuits
|
|
||||||
WHEN NEW.is_reserve = 0
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'forced reserve clear failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
repository.createInCircuit({
|
|
||||||
circuitId: "circuit-1",
|
|
||||||
name: "Steckdose",
|
|
||||||
displayName: "Steckdose",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.2,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
}),
|
|
||||||
/forced reserve clear failure/
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
|
|
||||||
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("commits a new circuit and all initial device rows together", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
|
|
||||||
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
|
||||||
const created = repository.createCircuitWithDeviceRows({
|
|
||||||
circuit: {
|
|
||||||
circuitListId: seedCircuit.circuitListId,
|
|
||||||
sectionId: seedCircuit.sectionId,
|
|
||||||
equipmentIdentifier: "-1F2",
|
|
||||||
displayName: "Beleuchtung",
|
|
||||||
sortOrder: 20,
|
|
||||||
},
|
|
||||||
deviceRows: [
|
|
||||||
{
|
|
||||||
name: "Leuchte",
|
|
||||||
displayName: "Leuchte 1",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
sortOrder: 15,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Leuchte",
|
|
||||||
displayName: "Leuchte 2",
|
|
||||||
quantity: 2,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 0.8,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [createdCircuit] = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.id, created.circuitId))
|
|
||||||
.all();
|
|
||||||
const createdRows = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(eq(circuitDeviceRows.circuitId, created.circuitId))
|
|
||||||
.all()
|
|
||||||
.sort((left, right) => left.sortOrder - right.sortOrder);
|
|
||||||
|
|
||||||
assert.equal(createdCircuit.equipmentIdentifier, "-1F2");
|
|
||||||
assert.equal(createdCircuit.isReserve, 0);
|
|
||||||
assert.deepEqual(
|
|
||||||
createdRows.map((row) => [row.id, row.displayName, row.sortOrder]),
|
|
||||||
[
|
|
||||||
[created.rowIds[0], "Leuchte 1", 15],
|
|
||||||
[created.rowIds[1], "Leuchte 2", 20],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back the circuit and earlier rows when an initial row insert fails", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_initial_device_row
|
|
||||||
BEFORE INSERT ON circuit_device_rows
|
|
||||||
WHEN NEW.name = 'Fail'
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'forced initial row failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
repository.createCircuitWithDeviceRows({
|
|
||||||
circuit: {
|
|
||||||
circuitListId: seedCircuit.circuitListId,
|
|
||||||
sectionId: seedCircuit.sectionId,
|
|
||||||
equipmentIdentifier: "-1F2",
|
|
||||||
displayName: "Rollback",
|
|
||||||
sortOrder: 20,
|
|
||||||
},
|
|
||||||
deviceRows: [
|
|
||||||
{
|
|
||||||
name: "First",
|
|
||||||
displayName: "Erste Zeile",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Fail",
|
|
||||||
displayName: "Fehlerhafte Zeile",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
/forced initial row failure/
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(context.db.select().from(circuits).all().length, 1);
|
|
||||||
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("commits the last-row deletion and activates the circuit reserve status together", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
insertDeviceRow(context);
|
|
||||||
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
|
||||||
|
|
||||||
repository.deleteFromCircuit("row-1", "circuit-1");
|
|
||||||
|
|
||||||
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
|
|
||||||
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back the row deletion when activating the reserve status fails", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
insertDeviceRow(context);
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_reserve_activation
|
|
||||||
BEFORE UPDATE OF is_reserve ON circuits
|
|
||||||
WHEN NEW.is_reserve = 1
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'forced reserve activation failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() => repository.deleteFromCircuit("row-1", "circuit-1"),
|
|
||||||
/forced reserve activation failure/
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 1);
|
|
||||||
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 0);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves rows to an existing circuit and updates both reserve states together", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
insertCircuit(context, {
|
|
||||||
id: "circuit-2",
|
|
||||||
equipmentIdentifier: "-1F2",
|
|
||||||
sortOrder: 20,
|
|
||||||
});
|
|
||||||
insertCircuit(context, {
|
|
||||||
id: "circuit-3",
|
|
||||||
equipmentIdentifier: "-1F3",
|
|
||||||
sortOrder: 30,
|
|
||||||
});
|
|
||||||
insertDeviceRow(context);
|
|
||||||
insertDeviceRow(context, {
|
|
||||||
id: "row-2",
|
|
||||||
circuitId: "circuit-2",
|
|
||||||
sortOrder: 10,
|
|
||||||
displayName: "Zweite Quellzeile",
|
|
||||||
});
|
|
||||||
insertDeviceRow(context, {
|
|
||||||
id: "target-row",
|
|
||||||
circuitId: "circuit-3",
|
|
||||||
sortOrder: 10,
|
|
||||||
displayName: "Bestehende Zielzeile",
|
|
||||||
});
|
|
||||||
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
|
||||||
|
|
||||||
const result = repository.moveRows({
|
|
||||||
rows: [
|
|
||||||
{ id: "row-1", expectedCircuitId: "circuit-1" },
|
|
||||||
{ id: "row-2", expectedCircuitId: "circuit-2" },
|
|
||||||
],
|
|
||||||
targetCircuitId: "circuit-3",
|
|
||||||
});
|
|
||||||
|
|
||||||
const movedRows = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.all()
|
|
||||||
.filter((row) => row.id === "row-1" || row.id === "row-2")
|
|
||||||
.sort((left, right) => left.sortOrder - right.sortOrder);
|
|
||||||
const persistedCircuits = context.db.select().from(circuits).all();
|
|
||||||
const targetCircuit = persistedCircuits.find(
|
|
||||||
(circuit) => circuit.id === "circuit-3"
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(result, {
|
|
||||||
movedRowIds: ["row-1", "row-2"],
|
|
||||||
targetCircuitId: "circuit-3",
|
|
||||||
createdCircuitId: undefined,
|
|
||||||
});
|
|
||||||
assert.deepEqual(
|
|
||||||
movedRows.map((row) => [row.id, row.circuitId, row.sortOrder]),
|
|
||||||
[
|
|
||||||
["row-1", "circuit-3", 20],
|
|
||||||
["row-2", "circuit-3", 30],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert.equal(targetCircuit?.isReserve, 0);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back an existing-target move when a reserve update fails", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
insertCircuit(context, {
|
|
||||||
id: "circuit-2",
|
|
||||||
equipmentIdentifier: "-1F2",
|
|
||||||
sortOrder: 20,
|
|
||||||
});
|
|
||||||
insertDeviceRow(context);
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_source_reserve_update
|
|
||||||
BEFORE UPDATE OF is_reserve ON circuits
|
|
||||||
WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'forced source reserve failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
repository.moveRows({
|
|
||||||
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
|
|
||||||
targetCircuitId: "circuit-2",
|
|
||||||
}),
|
|
||||||
/forced source reserve failure/
|
|
||||||
);
|
|
||||||
|
|
||||||
const [row] = context.db.select().from(circuitDeviceRows).all();
|
|
||||||
const persistedCircuits = context.db.select().from(circuits).all();
|
|
||||||
assert.equal(row.circuitId, "circuit-1");
|
|
||||||
assert.equal(
|
|
||||||
persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a new target circuit and moves rows into it atomically", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
insertDeviceRow(context);
|
|
||||||
const [sourceCircuit] = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.id, "circuit-1"))
|
|
||||||
.all();
|
|
||||||
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
|
||||||
|
|
||||||
const result = repository.moveRows({
|
|
||||||
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
|
|
||||||
createTargetCircuit: {
|
|
||||||
circuitListId: sourceCircuit.circuitListId,
|
|
||||||
sectionId: sourceCircuit.sectionId,
|
|
||||||
equipmentIdentifier: "-1F2",
|
|
||||||
displayName: "Neues Ziel",
|
|
||||||
sortOrder: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [movedRow] = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(eq(circuitDeviceRows.id, "row-1"))
|
|
||||||
.all();
|
|
||||||
const [createdCircuit] = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.id, result.createdCircuitId!))
|
|
||||||
.all();
|
|
||||||
const [updatedSource] = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.id, "circuit-1"))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
assert.equal(result.targetCircuitId, result.createdCircuitId);
|
|
||||||
assert.equal(createdCircuit.equipmentIdentifier, "-1F2");
|
|
||||||
assert.equal(createdCircuit.isReserve, 0);
|
|
||||||
assert.equal(movedRow.circuitId, createdCircuit.id);
|
|
||||||
assert.equal(updatedSource.isReserve, 1);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back a newly created target circuit when the move fails", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
insertDeviceRow(context);
|
|
||||||
const [sourceCircuit] = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.id, "circuit-1"))
|
|
||||||
.all();
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_new_target_source_reserve
|
|
||||||
BEFORE UPDATE OF is_reserve ON circuits
|
|
||||||
WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'forced new-target move failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
repository.moveRows({
|
|
||||||
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
|
|
||||||
createTargetCircuit: {
|
|
||||||
circuitListId: sourceCircuit.circuitListId,
|
|
||||||
sectionId: sourceCircuit.sectionId,
|
|
||||||
equipmentIdentifier: "-1F2",
|
|
||||||
displayName: "Rollback-Ziel",
|
|
||||||
sortOrder: 20,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
/forced new-target move failure/
|
|
||||||
);
|
|
||||||
|
|
||||||
const [row] = context.db.select().from(circuitDeviceRows).all();
|
|
||||||
const persistedCircuits = context.db.select().from(circuits).all();
|
|
||||||
assert.equal(row.circuitId, "circuit-1");
|
|
||||||
assert.equal(persistedCircuits.length, 1);
|
|
||||||
assert.equal(persistedCircuits[0].isReserve, 0);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { loadCircuitEditorSnapshot } from "../src/frontend/utils/circuit-editor-history.js";
|
||||||
|
import {
|
||||||
|
getProjectHistory,
|
||||||
|
redoProjectCommand,
|
||||||
|
undoProjectCommand,
|
||||||
|
} from "../src/frontend/utils/api.js";
|
||||||
|
import type {
|
||||||
|
CircuitTreeResponseDto,
|
||||||
|
ProjectHistoryStateDto,
|
||||||
|
} from "../src/frontend/types.js";
|
||||||
|
|
||||||
|
function tree(currentRevision: number): CircuitTreeResponseDto {
|
||||||
|
return {
|
||||||
|
id: "list-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
distributionBoardId: "board-1",
|
||||||
|
name: "Liste",
|
||||||
|
currentRevision,
|
||||||
|
sections: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function history(currentRevision: number): ProjectHistoryStateDto {
|
||||||
|
return {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision,
|
||||||
|
undoDepth: currentRevision,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: currentRevision > 0 ? `change-${currentRevision}` : null,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit editor history snapshot", () => {
|
||||||
|
it("returns tree and persistent history from the same project revision", async () => {
|
||||||
|
const snapshot = await loadCircuitEditorSnapshot(
|
||||||
|
async () => tree(4),
|
||||||
|
async () => history(4)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(snapshot.tree.currentRevision, 4);
|
||||||
|
assert.equal(snapshot.history.undoDepth, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries when the project changes between the tree and history reads", async () => {
|
||||||
|
const treeRevisions = [4, 5];
|
||||||
|
const historyRevisions = [5, 5];
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
const snapshot = await loadCircuitEditorSnapshot(
|
||||||
|
async () => tree(treeRevisions[attempts]),
|
||||||
|
async () => history(historyRevisions[attempts++])
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(attempts, 2);
|
||||||
|
assert.equal(snapshot.tree.currentRevision, 5);
|
||||||
|
assert.equal(snapshot.history.currentRevision, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a snapshot that remains inconsistent", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
loadCircuitEditorSnapshot(
|
||||||
|
async () => tree(4),
|
||||||
|
async () => history(5),
|
||||||
|
2
|
||||||
|
),
|
||||||
|
/während des Ladens geändert/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads history and sends revision-safe undo/redo requests", async () => {
|
||||||
|
const requests: Array<{ url: string; body?: unknown }> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
const url = String(input);
|
||||||
|
requests.push({
|
||||||
|
url,
|
||||||
|
...(init?.body
|
||||||
|
? { body: JSON.parse(String(init.body)) as unknown }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
const currentRevision = requests.length - 1;
|
||||||
|
const state = history(currentRevision);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(
|
||||||
|
init?.method === "POST"
|
||||||
|
? {
|
||||||
|
revision: {
|
||||||
|
revisionId: `revision-${currentRevision}`,
|
||||||
|
changeSetId: `change-${currentRevision}`,
|
||||||
|
projectId: "project-1",
|
||||||
|
revisionNumber: currentRevision,
|
||||||
|
createdAtIso: "2026-07-25T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
history: state,
|
||||||
|
}
|
||||||
|
: state
|
||||||
|
),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getProjectHistory("project-1");
|
||||||
|
await undoProjectCommand("project-1", 4);
|
||||||
|
await redoProjectCommand("project-1", 5);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{ url: "/api/projects/project-1/history" },
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/history/undo",
|
||||||
|
body: { expectedRevision: 4 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/history/redo",
|
||||||
|
body: { expectedRevision: 5 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
buildCircuitSectionRenumberAssignments,
|
||||||
|
} from "../src/frontend/utils/circuit-section-renumber-command.js";
|
||||||
|
import {
|
||||||
|
renumberCircuitSectionCommand,
|
||||||
|
} from "../src/frontend/utils/api.js";
|
||||||
|
import type {
|
||||||
|
CircuitTreeCircuitDto,
|
||||||
|
CircuitTreeSectionDto,
|
||||||
|
} from "../src/frontend/types.js";
|
||||||
|
|
||||||
|
function circuit(
|
||||||
|
id: string,
|
||||||
|
equipmentIdentifier: string,
|
||||||
|
sortOrder: number
|
||||||
|
): CircuitTreeCircuitDto {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier,
|
||||||
|
sortOrder,
|
||||||
|
isReserve: true,
|
||||||
|
circuitTotalPower: 0,
|
||||||
|
deviceRows: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(
|
||||||
|
id: string,
|
||||||
|
prefix: string,
|
||||||
|
circuits: CircuitTreeCircuitDto[]
|
||||||
|
): CircuitTreeSectionDto {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
key: id,
|
||||||
|
displayName: id,
|
||||||
|
prefix,
|
||||||
|
sortOrder: 10,
|
||||||
|
circuits: circuits.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
sectionId: id,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit section renumber command adapters", () => {
|
||||||
|
it("plans sequential identifiers and skips conflicts outside the section", () => {
|
||||||
|
const sections = [
|
||||||
|
section("target", "-1F", [
|
||||||
|
circuit("circuit-1", "-1F7", 10),
|
||||||
|
circuit("circuit-2", "-1F9", 20),
|
||||||
|
circuit("circuit-3", "-1F11", 30),
|
||||||
|
]),
|
||||||
|
section("other", "-2F", [
|
||||||
|
circuit("circuit-other", "-1F2", 10),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
buildCircuitSectionRenumberAssignments(sections, "target"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F7",
|
||||||
|
targetEquipmentIdentifier: "-1F1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F9",
|
||||||
|
targetEquipmentIdentifier: "-1F3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-3",
|
||||||
|
expectedEquipmentIdentifier: "-1F11",
|
||||||
|
targetEquipmentIdentifier: "-1F4",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits an already canonical section and rejects an unknown section", () => {
|
||||||
|
const sections = [
|
||||||
|
section("target", "-2F", [
|
||||||
|
circuit("circuit-1", "-2F1", 10),
|
||||||
|
circuit("circuit-2", "-2F2", 20),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
buildCircuitSectionRenumberAssignments(sections, "target"),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
buildCircuitSectionRenumberAssignments(
|
||||||
|
sections,
|
||||||
|
"missing"
|
||||||
|
),
|
||||||
|
/nicht gefunden/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the complete assignment set through project history", async () => {
|
||||||
|
let requestBody: Record<string, unknown> | null = null;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (_input, init) => {
|
||||||
|
requestBody = JSON.parse(
|
||||||
|
String(init?.body)
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
revision: {
|
||||||
|
revisionId: "revision-1",
|
||||||
|
changeSetId: "change-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
revisionNumber: 1,
|
||||||
|
createdAtIso: "2026-07-25T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 1,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: "change-1",
|
||||||
|
redoChangeSetId: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignments = [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-2F9",
|
||||||
|
targetEquipmentIdentifier: "-2F1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
await renumberCircuitSectionCommand(
|
||||||
|
"project-1",
|
||||||
|
0,
|
||||||
|
"section-1",
|
||||||
|
assignments,
|
||||||
|
"renumber"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(requestBody, {
|
||||||
|
expectedRevision: 0,
|
||||||
|
description: "renumber",
|
||||||
|
command: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.renumber-section",
|
||||||
|
payload: {
|
||||||
|
sectionId: "section-1",
|
||||||
|
assignments,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
createCircuitSectionsReorderProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
|
||||||
|
import {
|
||||||
|
buildCircuitSectionReorderAssignments,
|
||||||
|
} from "../src/frontend/utils/circuit-section-reorder-command.js";
|
||||||
|
import {
|
||||||
|
reorderCircuitSectionCommand,
|
||||||
|
reorderCircuitSectionsCommand,
|
||||||
|
} from "../src/frontend/utils/api.js";
|
||||||
|
import type { CircuitTreeCircuitDto } from "../src/frontend/types.js";
|
||||||
|
|
||||||
|
function circuit(
|
||||||
|
id: string,
|
||||||
|
sortOrder: number
|
||||||
|
): CircuitTreeCircuitDto {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier: id,
|
||||||
|
sortOrder,
|
||||||
|
isReserve: true,
|
||||||
|
circuitTotalPower: 0,
|
||||||
|
deviceRows: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit section reorder command adapters", () => {
|
||||||
|
it("builds complete exact assignments for a requested order", () => {
|
||||||
|
const circuits = [
|
||||||
|
circuit("circuit-1", 10),
|
||||||
|
circuit("circuit-2", 20),
|
||||||
|
circuit("circuit-3", 30),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
buildCircuitSectionReorderAssignments(circuits, [
|
||||||
|
"circuit-3",
|
||||||
|
"circuit-1",
|
||||||
|
"circuit-2",
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-3",
|
||||||
|
expectedSortOrder: 30,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 30,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits unchanged orders and rejects incomplete orders", () => {
|
||||||
|
const circuits = [
|
||||||
|
circuit("circuit-1", 10),
|
||||||
|
circuit("circuit-2", 20),
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
buildCircuitSectionReorderAssignments(circuits, [
|
||||||
|
"circuit-1",
|
||||||
|
"circuit-2",
|
||||||
|
]),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
buildCircuitSectionReorderAssignments(circuits, [
|
||||||
|
"circuit-1",
|
||||||
|
]),
|
||||||
|
/jeden Stromkreis/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates atomic multi-section reorder envelopes", () => {
|
||||||
|
const assignments = [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const command = createCircuitSectionsReorderProjectCommand([
|
||||||
|
{ sectionId: "section-1", assignments },
|
||||||
|
{
|
||||||
|
sectionId: "section-2",
|
||||||
|
assignments: assignments.map((assignment) => ({
|
||||||
|
...assignment,
|
||||||
|
circuitId: `${assignment.circuitId}-other`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(command.type, "circuit.reorder-sections");
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitSectionsReorderProjectCommand([
|
||||||
|
{ sectionId: "section-1", assignments },
|
||||||
|
{ sectionId: "section-1", assignments },
|
||||||
|
]),
|
||||||
|
/duplicate section ids/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends single and multi-section commands through project history", async () => {
|
||||||
|
const requests: Array<Record<string, unknown>> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (_input, init) => {
|
||||||
|
requests.push(
|
||||||
|
JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||||
|
);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
revision: {
|
||||||
|
revisionId: "revision-1",
|
||||||
|
changeSetId: "change-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
revisionNumber: requests.length,
|
||||||
|
createdAtIso: "2026-07-25T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: requests.length,
|
||||||
|
undoDepth: requests.length,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: "change-1",
|
||||||
|
redoChangeSetId: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignments = [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
await reorderCircuitSectionCommand(
|
||||||
|
"project-1",
|
||||||
|
0,
|
||||||
|
"section-1",
|
||||||
|
assignments,
|
||||||
|
"single"
|
||||||
|
);
|
||||||
|
await reorderCircuitSectionsCommand(
|
||||||
|
"project-1",
|
||||||
|
1,
|
||||||
|
[{ sectionId: "section-1", assignments }],
|
||||||
|
"multiple"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
requests.map((request) => {
|
||||||
|
const command = request.command as { type: string };
|
||||||
|
return [request.expectedRevision, command.type];
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
[0, "circuit.reorder-section"],
|
||||||
|
[1, "circuit.reorder-sections"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,10 +16,12 @@ import { circuits } from "../src/db/schema/circuits.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 { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
|
import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
|
||||||
|
|
||||||
interface TestFixture {
|
interface TestFixture {
|
||||||
context: DatabaseContext;
|
context: DatabaseContext;
|
||||||
sectionId: string;
|
sectionId: string;
|
||||||
|
secondSectionId: string;
|
||||||
foreignSectionId: string;
|
foreignSectionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,17 +46,20 @@ function createTestDatabase(): TestFixture {
|
|||||||
"project-2",
|
"project-2",
|
||||||
"UV-02"
|
"UV-02"
|
||||||
);
|
);
|
||||||
const section = context.db
|
const projectSections = context.db
|
||||||
.select()
|
.select()
|
||||||
.from(circuitSections)
|
.from(circuitSections)
|
||||||
.where(eq(circuitSections.circuitListId, board.id))
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
.get();
|
.all();
|
||||||
|
const section = projectSections[0];
|
||||||
|
const secondSection = projectSections[1];
|
||||||
const foreignSection = context.db
|
const foreignSection = context.db
|
||||||
.select()
|
.select()
|
||||||
.from(circuitSections)
|
.from(circuitSections)
|
||||||
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||||
.get();
|
.get();
|
||||||
assert.ok(section);
|
assert.ok(section);
|
||||||
|
assert.ok(secondSection);
|
||||||
assert.ok(foreignSection);
|
assert.ok(foreignSection);
|
||||||
|
|
||||||
context.db
|
context.db
|
||||||
@@ -107,6 +112,7 @@ function createTestDatabase(): TestFixture {
|
|||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
sectionId: section.id,
|
sectionId: section.id,
|
||||||
|
secondSectionId: secondSection.id,
|
||||||
foreignSectionId: foreignSection.id,
|
foreignSectionId: foreignSection.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -226,6 +232,197 @@ describe("circuit section reorder project-command repository", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reorders multiple sections atomically with one undo step", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const firstCircuit = fixture.context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(firstCircuit);
|
||||||
|
fixture.context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-4",
|
||||||
|
circuitListId: firstCircuit.circuitListId,
|
||||||
|
sectionId: fixture.secondSectionId,
|
||||||
|
equipmentIdentifier: "-2F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-5",
|
||||||
|
circuitListId: firstCircuit.circuitListId,
|
||||||
|
sectionId: fixture.secondSectionId,
|
||||||
|
equipmentIdentifier: "-2F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const store =
|
||||||
|
new CircuitSectionReorderProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const command = createCircuitSectionsReorderProjectCommand([
|
||||||
|
{
|
||||||
|
sectionId: fixture.sectionId,
|
||||||
|
assignments: createReorderCommand(fixture.sectionId)
|
||||||
|
.payload.assignments,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionId: fixture.secondSectionId,
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-4",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-5",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const reordered = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
fixture.context.db
|
||||||
|
.select({ id: circuits.id, sortOrder: circuits.sortOrder })
|
||||||
|
.from(circuits)
|
||||||
|
.all()
|
||||||
|
.filter((circuit) => circuit.id !== "circuit-foreign")
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id)),
|
||||||
|
[
|
||||||
|
{ id: "circuit-1", sortOrder: 20 },
|
||||||
|
{ id: "circuit-2", sortOrder: 30 },
|
||||||
|
{ id: "circuit-3", sortOrder: 10 },
|
||||||
|
{ id: "circuit-4", sortOrder: 20 },
|
||||||
|
{ id: "circuit-5", sortOrder: 10 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
reordered.revision.changeSetId,
|
||||||
|
command: reordered.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
fixture.context.db
|
||||||
|
.select({ id: circuits.id, sortOrder: circuits.sortOrder })
|
||||||
|
.from(circuits)
|
||||||
|
.all()
|
||||||
|
.filter((circuit) => circuit.id !== "circuit-foreign")
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id)),
|
||||||
|
[
|
||||||
|
{ id: "circuit-1", sortOrder: 10 },
|
||||||
|
{ id: "circuit-2", sortOrder: 20 },
|
||||||
|
{ id: "circuit-3", sortOrder: 30 },
|
||||||
|
{ id: "circuit-4", sortOrder: 10 },
|
||||||
|
{ id: "circuit-5", sortOrder: 20 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
new ProjectHistoryRepository(fixture.context.db).getState(
|
||||||
|
"project-1"
|
||||||
|
)?.redoDepth,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back every section when a later section is stale", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const firstCircuit = fixture.context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(firstCircuit);
|
||||||
|
fixture.context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-4",
|
||||||
|
circuitListId: firstCircuit.circuitListId,
|
||||||
|
sectionId: fixture.secondSectionId,
|
||||||
|
equipmentIdentifier: "-2F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-5",
|
||||||
|
circuitListId: firstCircuit.circuitListId,
|
||||||
|
sectionId: fixture.secondSectionId,
|
||||||
|
equipmentIdentifier: "-2F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
const command = createCircuitSectionsReorderProjectCommand([
|
||||||
|
{
|
||||||
|
sectionId: fixture.sectionId,
|
||||||
|
assignments: createReorderCommand(fixture.sectionId)
|
||||||
|
.payload.assignments,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionId: fixture.secondSectionId,
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-4",
|
||||||
|
expectedSortOrder: 999,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-5",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
new CircuitSectionReorderProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
}),
|
||||||
|
/changed before/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getCircuitState(fixture.context).map(
|
||||||
|
(circuit) => circuit.sortOrder
|
||||||
|
),
|
||||||
|
[10, 20, 30, 10, 20]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all()
|
||||||
|
.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects incomplete, stale and foreign-section reorders", () => {
|
it("rejects incomplete, stale and foreign-section reorders", () => {
|
||||||
const fixture = createTestDatabase();
|
const fixture = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { asc, eq } from "drizzle-orm";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import {
|
|
||||||
createDatabaseContext,
|
|
||||||
type DatabaseContext,
|
|
||||||
} from "../src/db/database-context.js";
|
|
||||||
import { CircuitSectionTransactionRepository } from "../src/db/repositories/circuit-section-transaction.repository.js";
|
|
||||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
|
||||||
import { projects } from "../src/db/schema/projects.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" }).run();
|
|
||||||
const board = new DistributionBoardRepository(
|
|
||||||
context.db
|
|
||||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
|
||||||
const [section] = context.db
|
|
||||||
.select()
|
|
||||||
.from(circuitSections)
|
|
||||||
.where(eq(circuitSections.circuitListId, board.id))
|
|
||||||
.limit(1)
|
|
||||||
.all();
|
|
||||||
context.db
|
|
||||||
.insert(circuits)
|
|
||||||
.values([
|
|
||||||
{
|
|
||||||
id: "circuit-1",
|
|
||||||
circuitListId: board.id,
|
|
||||||
sectionId: section.id,
|
|
||||||
equipmentIdentifier: "-1F1",
|
|
||||||
displayName: "Stromkreis 1",
|
|
||||||
sortOrder: 10,
|
|
||||||
isReserve: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "circuit-2",
|
|
||||||
circuitListId: board.id,
|
|
||||||
sectionId: section.id,
|
|
||||||
equipmentIdentifier: "-1F2",
|
|
||||||
displayName: "Stromkreis 2",
|
|
||||||
sortOrder: 20,
|
|
||||||
isReserve: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "circuit-3",
|
|
||||||
circuitListId: board.id,
|
|
||||||
sectionId: section.id,
|
|
||||||
equipmentIdentifier: "-1F3",
|
|
||||||
displayName: "Stromkreis 3",
|
|
||||||
sortOrder: 30,
|
|
||||||
isReserve: 1,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.run();
|
|
||||||
context.db
|
|
||||||
.insert(circuitDeviceRows)
|
|
||||||
.values({
|
|
||||||
id: "row-1",
|
|
||||||
circuitId: "circuit-1",
|
|
||||||
sortOrder: 10,
|
|
||||||
name: "Leuchte",
|
|
||||||
displayName: "Leuchte",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listCircuits(context: DatabaseContext) {
|
|
||||||
return context.db
|
|
||||||
.select()
|
|
||||||
.from(circuits)
|
|
||||||
.orderBy(asc(circuits.id))
|
|
||||||
.all();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("circuit-section transaction repository", () => {
|
|
||||||
it("commits identifier swaps without violating the circuit-list uniqueness constraint", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const persisted = listCircuits(context);
|
|
||||||
const repository = new CircuitSectionTransactionRepository(context.db);
|
|
||||||
|
|
||||||
repository.updateEquipmentIdentifiers(
|
|
||||||
persisted[0].circuitListId,
|
|
||||||
[
|
|
||||||
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
|
|
||||||
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
|
|
||||||
],
|
|
||||||
persisted[0].sectionId
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
|
|
||||||
["-1F2", "-1F1", "-1F3"]
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
context.db.select().from(circuitDeviceRows).all()[0].circuitId,
|
|
||||||
"circuit-1"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back temporary and final identifiers when a later update fails", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const persisted = listCircuits(context);
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_second_final_identifier
|
|
||||||
BEFORE UPDATE OF equipment_identifier ON circuits
|
|
||||||
WHEN OLD.id = 'circuit-2' AND NEW.equipment_identifier = '-1F1'
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'forced identifier failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new CircuitSectionTransactionRepository(context.db);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
repository.updateEquipmentIdentifiers(
|
|
||||||
persisted[0].circuitListId,
|
|
||||||
[
|
|
||||||
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
|
|
||||||
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
|
|
||||||
],
|
|
||||||
persisted[0].sectionId
|
|
||||||
),
|
|
||||||
/forced identifier failure/
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
|
|
||||||
["-1F1", "-1F2", "-1F3"]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("commits a complete section reorder without changing identifiers", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const persisted = listCircuits(context);
|
|
||||||
const repository = new CircuitSectionTransactionRepository(context.db);
|
|
||||||
|
|
||||||
repository.updateSortOrders(persisted[0].sectionId, [
|
|
||||||
"circuit-3",
|
|
||||||
"circuit-1",
|
|
||||||
"circuit-2",
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
listCircuits(context).map((circuit) => [
|
|
||||||
circuit.id,
|
|
||||||
circuit.sortOrder,
|
|
||||||
circuit.equipmentIdentifier,
|
|
||||||
]),
|
|
||||||
[
|
|
||||||
["circuit-1", 20, "-1F1"],
|
|
||||||
["circuit-2", 30, "-1F2"],
|
|
||||||
["circuit-3", 10, "-1F3"],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back earlier sort orders when a later update fails", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const persisted = listCircuits(context);
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_last_sort_order
|
|
||||||
BEFORE UPDATE OF sort_order ON circuits
|
|
||||||
WHEN OLD.id = 'circuit-2' AND NEW.sort_order = 30
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'forced sort failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new CircuitSectionTransactionRepository(context.db);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
repository.updateSortOrders(persisted[0].sectionId, [
|
|
||||||
"circuit-3",
|
|
||||||
"circuit-1",
|
|
||||||
"circuit-2",
|
|
||||||
]),
|
|
||||||
/forced sort failure/
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
listCircuits(context).map((circuit) => circuit.sortOrder),
|
|
||||||
[10, 20, 30]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
|
|
||||||
|
|
||||||
describe("circuit write service rules", () => {
|
|
||||||
it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
|
|
||||||
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "s1", circuitListId: "l1", prefix: "-2F" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async listBySection() {
|
|
||||||
return [
|
|
||||||
{ id: "cB", sectionId: "s1", equipmentIdentifier: "-2F2", sortOrder: 10, isReserve: 0 },
|
|
||||||
{ id: "cA", sectionId: "s1", equipmentIdentifier: "-2F1", sortOrder: 20, isReserve: 1 },
|
|
||||||
] as never[];
|
|
||||||
},
|
|
||||||
async listByCircuitList() {
|
|
||||||
return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[];
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitSectionTransactionStore: {
|
|
||||||
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
|
|
||||||
safeUpdatePayload = updates;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.renumberSection("s1");
|
|
||||||
assert.deepEqual(safeUpdatePayload, [
|
|
||||||
{ id: "cB", equipmentIdentifier: "-2F1" },
|
|
||||||
{ id: "cA", equipmentIdentifier: "-2F2" },
|
|
||||||
]);
|
|
||||||
assert.equal(result.length, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renumber shifts forward/backward and respects other sections", async () => {
|
|
||||||
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async listBySection() {
|
|
||||||
return [
|
|
||||||
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F5", sortOrder: 10, isReserve: 0 },
|
|
||||||
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 20, isReserve: 0 },
|
|
||||||
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-1F9", sortOrder: 30, isReserve: 0 },
|
|
||||||
] as never[];
|
|
||||||
},
|
|
||||||
async listByCircuitList() {
|
|
||||||
return [{ id: "o1", sectionId: "s2", equipmentIdentifier: "-1F2" }] as never[];
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitSectionTransactionStore: {
|
|
||||||
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
|
|
||||||
safeUpdatePayload = updates;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.renumberSection("s1");
|
|
||||||
assert.deepEqual(safeUpdatePayload, [
|
|
||||||
{ id: "c2", equipmentIdentifier: "-1F1" },
|
|
||||||
{ id: "c1", equipmentIdentifier: "-1F3" },
|
|
||||||
{ id: "c3", equipmentIdentifier: "-1F4" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renumber handles gaps and keeps device rows untouched by identifier-only update path", async () => {
|
|
||||||
let safeCalled = 0;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async listBySection() {
|
|
||||||
return [
|
|
||||||
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 },
|
|
||||||
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F5", sortOrder: 20, isReserve: 0 },
|
|
||||||
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-1F9", sortOrder: 30, isReserve: 0 },
|
|
||||||
] as never[];
|
|
||||||
},
|
|
||||||
async listByCircuitList() {
|
|
||||||
return [] as never[];
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitSectionTransactionStore: {
|
|
||||||
updateEquipmentIdentifiers() {
|
|
||||||
safeCalled += 1;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowRepository: {
|
|
||||||
async update() {
|
|
||||||
throw new Error("device rows must not be touched");
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.renumberSection("s1");
|
|
||||||
assert.equal(safeCalled, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moving a device row to another circuit preserves row and toggles reserve flags", async () => {
|
|
||||||
let transactionalMove:
|
|
||||||
| {
|
|
||||||
rows: Array<{ id: string; expectedCircuitId: string }>;
|
|
||||||
targetCircuitId?: string;
|
|
||||||
createTargetCircuit?: unknown;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
deviceRowRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "r1", circuitId: "c1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowTransactionStore: {
|
|
||||||
moveRows(input: typeof transactionalMove) {
|
|
||||||
transactionalMove = input;
|
|
||||||
return {
|
|
||||||
movedRowIds: input!.rows.map((row) => row.id),
|
|
||||||
targetCircuitId: input!.targetCircuitId!,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async findById(circuitId: string) {
|
|
||||||
if (circuitId === "c1") {
|
|
||||||
return {
|
|
||||||
id: "c1",
|
|
||||||
sectionId: "s1",
|
|
||||||
circuitListId: "l1",
|
|
||||||
equipmentIdentifier: "-1F1",
|
|
||||||
sortOrder: 10,
|
|
||||||
isReserve: 0,
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: "c2",
|
|
||||||
sectionId: "s1",
|
|
||||||
circuitListId: "l1",
|
|
||||||
equipmentIdentifier: "-1F2",
|
|
||||||
sortOrder: 20,
|
|
||||||
isReserve: 1,
|
|
||||||
} as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.moveDeviceRow("r1", { targetCircuitId: "c2" });
|
|
||||||
assert.deepEqual(transactionalMove, {
|
|
||||||
rows: [{ id: "r1", expectedCircuitId: "c1" }],
|
|
||||||
targetCircuitId: "c2",
|
|
||||||
createTargetCircuit: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moving a device row to placeholder creates a new circuit in target section", async () => {
|
|
||||||
let preparedTarget:
|
|
||||||
| {
|
|
||||||
circuitListId: string;
|
|
||||||
sectionId: string;
|
|
||||||
equipmentIdentifier: string;
|
|
||||||
displayName: string;
|
|
||||||
sortOrder: number;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
deviceRowRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "r1", circuitId: "c1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowTransactionStore: {
|
|
||||||
moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
|
|
||||||
preparedTarget = input.createTargetCircuit;
|
|
||||||
return {
|
|
||||||
movedRowIds: input.rows.map((row) => row.id),
|
|
||||||
targetCircuitId: "c-new",
|
|
||||||
createdCircuitId: "c-new",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async findById(circuitId: string) {
|
|
||||||
if (circuitId === "c1") {
|
|
||||||
return {
|
|
||||||
id: "c1",
|
|
||||||
sectionId: "s1",
|
|
||||||
circuitListId: "l1",
|
|
||||||
equipmentIdentifier: "-1F1",
|
|
||||||
sortOrder: 10,
|
|
||||||
isReserve: 0,
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
return null as never;
|
|
||||||
},
|
|
||||||
async listBySection() {
|
|
||||||
return [{ sortOrder: 40 }] as never[];
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "s2", circuitListId: "l1", prefix: "-2F" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
numberingService: {
|
|
||||||
async getNextIdentifier() {
|
|
||||||
return "-2F8";
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.moveDeviceRow("r1", { targetSectionId: "s2", createNewCircuit: true });
|
|
||||||
assert.deepEqual(preparedTarget, {
|
|
||||||
circuitListId: "l1",
|
|
||||||
sectionId: "s2",
|
|
||||||
equipmentIdentifier: "-2F8",
|
|
||||||
displayName: "Neuer Stromkreis",
|
|
||||||
sortOrder: 50,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moving a device row to its current circuit remains a no-op", async () => {
|
|
||||||
let transactionalMoveCount = 0;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
deviceRowRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "r1", circuitId: "c1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowTransactionStore: {
|
|
||||||
moveRows() {
|
|
||||||
transactionalMoveCount += 1;
|
|
||||||
throw new Error("same-circuit move must not write");
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async findById() {
|
|
||||||
return {
|
|
||||||
id: "c1",
|
|
||||||
sectionId: "s1",
|
|
||||||
circuitListId: "l1",
|
|
||||||
equipmentIdentifier: "-1F1",
|
|
||||||
sortOrder: 10,
|
|
||||||
isReserve: 0,
|
|
||||||
} as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.moveDeviceRow("r1", { targetCircuitId: "c1" });
|
|
||||||
assert.equal(transactionalMoveCount, 0);
|
|
||||||
assert.equal(result?.id, "r1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moving multiple device rows to a circuit preserves input order and toggles reserve", async () => {
|
|
||||||
let transactionalMove:
|
|
||||||
| {
|
|
||||||
rows: Array<{ id: string; expectedCircuitId: string }>;
|
|
||||||
targetCircuitId?: string;
|
|
||||||
createTargetCircuit?: unknown;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
deviceRowRepository: {
|
|
||||||
async findById(rowId: string) {
|
|
||||||
if (rowId === "r1") {
|
|
||||||
return { id: "r1", circuitId: "c1" } as never;
|
|
||||||
}
|
|
||||||
return { id: "r2", circuitId: "c2" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowTransactionStore: {
|
|
||||||
moveRows(input: typeof transactionalMove) {
|
|
||||||
transactionalMove = input;
|
|
||||||
return {
|
|
||||||
movedRowIds: input!.rows.map((row) => row.id),
|
|
||||||
targetCircuitId: input!.targetCircuitId!,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async findById(circuitId: string) {
|
|
||||||
if (circuitId === "c1") {
|
|
||||||
return { id: "c1", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
|
|
||||||
}
|
|
||||||
if (circuitId === "c2") {
|
|
||||||
return { id: "c2", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 } as never;
|
|
||||||
}
|
|
||||||
return { id: "c3", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F3", sortOrder: 30, isReserve: 1 } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.moveDeviceRowsBulk({ rowIds: ["r1", "r2"], targetCircuitId: "c3" });
|
|
||||||
assert.deepEqual(transactionalMove, {
|
|
||||||
rows: [
|
|
||||||
{ id: "r1", expectedCircuitId: "c1" },
|
|
||||||
{ id: "r2", expectedCircuitId: "c2" },
|
|
||||||
],
|
|
||||||
targetCircuitId: "c3",
|
|
||||||
createTargetCircuit: undefined,
|
|
||||||
});
|
|
||||||
assert.deepEqual(result, {
|
|
||||||
movedRowIds: ["r1", "r2"],
|
|
||||||
targetCircuitId: "c3",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moving multiple device rows to placeholder creates exactly one new circuit", async () => {
|
|
||||||
let transactionCount = 0;
|
|
||||||
let preparedTarget:
|
|
||||||
| {
|
|
||||||
circuitListId: string;
|
|
||||||
sectionId: string;
|
|
||||||
equipmentIdentifier: string;
|
|
||||||
displayName: string;
|
|
||||||
sortOrder: number;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
deviceRowRepository: {
|
|
||||||
async findById(rowId: string) {
|
|
||||||
return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowTransactionStore: {
|
|
||||||
moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
|
|
||||||
transactionCount += 1;
|
|
||||||
preparedTarget = input.createTargetCircuit;
|
|
||||||
return {
|
|
||||||
movedRowIds: input.rows.map((row) => row.id),
|
|
||||||
targetCircuitId: "c-new",
|
|
||||||
createdCircuitId: "c-new",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async findById(circuitId: string) {
|
|
||||||
if (circuitId === "c1" || circuitId === "c2") {
|
|
||||||
return { id: circuitId, sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
|
|
||||||
}
|
|
||||||
return null as never;
|
|
||||||
},
|
|
||||||
async listBySection() {
|
|
||||||
return [{ sortOrder: 30 }] as never[];
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "s2", circuitListId: "l1", prefix: "-2F" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
numberingService: {
|
|
||||||
async getNextIdentifier() {
|
|
||||||
return "-2F8";
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.moveDeviceRowsBulk({
|
|
||||||
rowIds: ["r1", "r2"],
|
|
||||||
targetSectionId: "s2",
|
|
||||||
createNewCircuit: true,
|
|
||||||
});
|
|
||||||
assert.equal(transactionCount, 1);
|
|
||||||
assert.deepEqual(preparedTarget, {
|
|
||||||
circuitListId: "l1",
|
|
||||||
sectionId: "s2",
|
|
||||||
equipmentIdentifier: "-2F8",
|
|
||||||
displayName: "Neuer Stromkreis",
|
|
||||||
sortOrder: 40,
|
|
||||||
});
|
|
||||||
assert.equal(result.createdCircuitId, "c-new");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reorders circuits inside one section without renumbering identifiers", async () => {
|
|
||||||
let safeReorder: { sectionId: string; circuitIds: string[] } | undefined;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "s1", circuitListId: "l1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async listBySection() {
|
|
||||||
return [
|
|
||||||
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-2F7", sortOrder: 10, isReserve: 0 },
|
|
||||||
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-2F9", sortOrder: 20, isReserve: 0 },
|
|
||||||
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
|
|
||||||
] as never[];
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitSectionTransactionStore: {
|
|
||||||
updateSortOrders(sectionId: string, circuitIds: string[]) {
|
|
||||||
safeReorder = { sectionId, circuitIds };
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.reorderCircuitsInSection("s1", { orderedCircuitIds: ["c3", "c1", "c2"] });
|
|
||||||
assert.deepEqual(safeReorder, {
|
|
||||||
sectionId: "s1",
|
|
||||||
circuitIds: ["c3", "c1", "c2"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("safe section identifier bulk update validates full section set (undo safety)", async () => {
|
|
||||||
let safeCalled = false;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async listBySection() {
|
|
||||||
return [
|
|
||||||
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 },
|
|
||||||
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 },
|
|
||||||
] as never[];
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitSectionTransactionStore: {
|
|
||||||
updateEquipmentIdentifiers() {
|
|
||||||
safeCalled = true;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
await service.updateSectionEquipmentIdentifiers("s1", {
|
|
||||||
identifiers: [
|
|
||||||
{ circuitId: "c1", equipmentIdentifier: "-1F2" },
|
|
||||||
{ circuitId: "c2", equipmentIdentifier: "-1F1" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
assert.equal(safeCalled, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
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 { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
|
import {
|
||||||
|
createDistributionBoardInsertProjectCommand,
|
||||||
|
createDistributionBoardStructureSnapshot,
|
||||||
|
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
|
||||||
|
import { createDistributionBoard } from "../src/frontend/utils/api.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 getStructureCounts(context: DatabaseContext) {
|
||||||
|
return {
|
||||||
|
boards: context.db.select().from(distributionBoards).all().length,
|
||||||
|
lists: context.db.select().from(circuitLists).all().length,
|
||||||
|
sections: context.db.select().from(circuitSections).all().length,
|
||||||
|
revisions: context.db.select().from(projectRevisions).all().length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("distribution-board structure project command", () => {
|
||||||
|
it("builds the fixed setup and sends the expected frontend revision", async () => {
|
||||||
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
" UV-02 "
|
||||||
|
);
|
||||||
|
assert.equal(structure.distributionBoard.name, "UV-02");
|
||||||
|
assert.equal(
|
||||||
|
structure.circuitList.name,
|
||||||
|
"UV-02 Stromkreisliste"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
structure.sections.map((section) => [
|
||||||
|
section.key,
|
||||||
|
section.prefix,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
["lighting", "-1F"],
|
||||||
|
["single_phase", "-2F"],
|
||||||
|
["three_phase", "-3F"],
|
||||||
|
["unassigned", "-UF"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createDistributionBoardInsertProjectCommand({
|
||||||
|
...structure,
|
||||||
|
sections: structure.sections.slice(0, 3),
|
||||||
|
}),
|
||||||
|
/incomplete/
|
||||||
|
);
|
||||||
|
|
||||||
|
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 createDistributionBoard("project-1", "UV-02", 7);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/distribution-boards",
|
||||||
|
body: { name: "UV-02", expectedRevision: 7 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates stable structure and supports persisted undo and redo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const history = new ProjectHistoryRepository(context.db);
|
||||||
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const command =
|
||||||
|
createDistributionBoardInsertProjectCommand(structure);
|
||||||
|
const forward = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getStructureCounts(context), {
|
||||||
|
boards: 1,
|
||||||
|
lists: 1,
|
||||||
|
sections: 4,
|
||||||
|
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: forward.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getStructureCounts(context), {
|
||||||
|
boards: 0,
|
||||||
|
lists: 0,
|
||||||
|
sections: 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(getStructureCounts(context), {
|
||||||
|
boards: 1,
|
||||||
|
lists: 1,
|
||||||
|
sections: 4,
|
||||||
|
revisions: 3,
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
distributionBoards.id,
|
||||||
|
structure.distributionBoard.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects foreign, stale and changed structures without partial writes", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const foreign = createDistributionBoardInsertProjectCommand(
|
||||||
|
createDistributionBoardStructureSnapshot(
|
||||||
|
"project-2",
|
||||||
|
"UV fremd"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: foreign,
|
||||||
|
}),
|
||||||
|
/does not belong/
|
||||||
|
);
|
||||||
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const command =
|
||||||
|
createDistributionBoardInsertProjectCommand(structure);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
}),
|
||||||
|
ProjectRevisionConflictError
|
||||||
|
);
|
||||||
|
assert.deepEqual(getStructureCounts(context), {
|
||||||
|
boards: 0,
|
||||||
|
lists: 0,
|
||||||
|
sections: 0,
|
||||||
|
revisions: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inserted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({ name: "Direkt geändert" })
|
||||||
|
.where(eq(distributionBoards.id, structure.distributionBoard.id))
|
||||||
|
.run();
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
}),
|
||||||
|
/changed before deletion/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses populated deletion and rolls back late history failures", () => {
|
||||||
|
const populatedContext = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(
|
||||||
|
populatedContext.db
|
||||||
|
);
|
||||||
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const inserted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command:
|
||||||
|
createDistributionBoardInsertProjectCommand(structure),
|
||||||
|
});
|
||||||
|
populatedContext.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: structure.circuitList.id,
|
||||||
|
sectionId: structure.sections[0].id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const undoStep = new ProjectHistoryRepository(
|
||||||
|
populatedContext.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,
|
||||||
|
}),
|
||||||
|
/populated/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
populatedContext.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollbackContext = createTestDatabase();
|
||||||
|
try {
|
||||||
|
rollbackContext.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_distribution_board_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced distribution-board history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(
|
||||||
|
rollbackContext.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardInsertProjectCommand(
|
||||||
|
createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV rollback"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/forced distribution-board history failure/
|
||||||
|
);
|
||||||
|
assert.deepEqual(getStructureCounts(rollbackContext), {
|
||||||
|
boards: 0,
|
||||||
|
lists: 0,
|
||||||
|
sections: 0,
|
||||||
|
revisions: 0,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rollbackContext.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,10 +15,13 @@ import { CircuitSectionReorderProjectCommandRepository } from "../src/db/reposit
|
|||||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||||
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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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";
|
||||||
|
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 { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
@@ -36,11 +39,17 @@ import {
|
|||||||
import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
|
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
|
||||||
import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
|
import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
|
||||||
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
|
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
|
||||||
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
createDistributionBoardInsertProjectCommand,
|
||||||
|
createDistributionBoardStructureSnapshot,
|
||||||
|
} from "../src/domain/models/distribution-board-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";
|
||||||
|
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
|
||||||
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
function createTestDatabase(): DatabaseContext {
|
||||||
@@ -95,11 +104,14 @@ function createService(context: DatabaseContext) {
|
|||||||
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
|
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
|
||||||
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
|
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
|
||||||
new CircuitStructureProjectCommandRepository(context.db),
|
new CircuitStructureProjectCommandRepository(context.db),
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(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),
|
||||||
new ProjectDeviceProjectCommandRepository(context.db),
|
new ProjectDeviceProjectCommandRepository(context.db),
|
||||||
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
||||||
|
new ProjectSettingsProjectCommandRepository(context.db),
|
||||||
|
new ProjectStateRestoreCommandRepository(context.db),
|
||||||
new ProjectHistoryRepository(context.db)
|
new ProjectHistoryRepository(context.db)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -764,6 +776,34 @@ describe("project command service", () => {
|
|||||||
{ id: "circuit-2", sortOrder: 20 },
|
{ id: "circuit-2", sortOrder: 20 },
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const multiSection = createService(context).executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
command: createCircuitSectionsReorderProjectCommand([
|
||||||
|
{
|
||||||
|
sectionId: firstCircuit.sectionId,
|
||||||
|
assignments:
|
||||||
|
createCircuitSectionReorderProjectCommand(
|
||||||
|
firstCircuit.sectionId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
).payload.assignments,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
assert.equal(multiSection.history.currentRevision, 3);
|
||||||
|
assert.equal(multiSection.history.redoDepth, 0);
|
||||||
} finally {
|
} finally {
|
||||||
context.close();
|
context.close();
|
||||||
}
|
}
|
||||||
@@ -893,6 +933,109 @@ describe("project command service", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dispatches project settings updates through persistent history", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
const updated = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createProjectSettingsUpdateProjectCommand({
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(updated.history.currentRevision, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({
|
||||||
|
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.get(),
|
||||||
|
{
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const undone = service.undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(undone.history.currentRevision, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({
|
||||||
|
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.get(),
|
||||||
|
{
|
||||||
|
singlePhaseVoltageV: 230,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches distribution-board setup and its persisted inverse", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const created = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command:
|
||||||
|
createDistributionBoardInsertProjectCommand(structure),
|
||||||
|
});
|
||||||
|
assert.equal(created.history.currentRevision, 1);
|
||||||
|
assert.ok(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
circuitSections.circuitListId,
|
||||||
|
structure.circuitList.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
);
|
||||||
|
|
||||||
|
const undone = createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(undone.history.currentRevision, 2);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
circuitSections.circuitListId,
|
||||||
|
structure.circuitList.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} 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,38 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { listProjectRevisionsQuerySchema } from "../src/shared/validation/project-history.schemas.js";
|
||||||
|
|
||||||
|
describe("project revision timeline query", () => {
|
||||||
|
it("applies a bounded default page size", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
listProjectRevisionsQuerySchema.parse({}),
|
||||||
|
{ limit: 25 }
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
listProjectRevisionsQuerySchema.parse({
|
||||||
|
limit: "100",
|
||||||
|
beforeRevision: "42",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
limit: 100,
|
||||||
|
beforeRevision: 42,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid cursors, page sizes and unknown query fields", () => {
|
||||||
|
for (const query of [
|
||||||
|
{ limit: "0" },
|
||||||
|
{ limit: "101" },
|
||||||
|
{ limit: "1.5" },
|
||||||
|
{ beforeRevision: "0" },
|
||||||
|
{ beforeRevision: "not-a-number" },
|
||||||
|
{ unexpected: "value" },
|
||||||
|
]) {
|
||||||
|
assert.equal(
|
||||||
|
listProjectRevisionsQuerySchema.safeParse(query).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -83,6 +83,150 @@ function getRowQuantity(context: DatabaseContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("project history repository", () => {
|
describe("project history repository", () => {
|
||||||
|
it("lists revision metadata in stable descending pages without payloads", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const circuitsStore = new CircuitProjectCommandRepository(context.db);
|
||||||
|
const rowsStore = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||||
|
const first = circuitsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
actorId: "planner-1",
|
||||||
|
description: "Stromkreis umbenennen",
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Neu",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const second = rowsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
description: "Anzahl ändern",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const third = rowsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "undo",
|
||||||
|
description: "Anzahl zurücknehmen",
|
||||||
|
historyTargetChangeSetId: second.revision.changeSetId,
|
||||||
|
command: second.inverse,
|
||||||
|
});
|
||||||
|
const history = new ProjectHistoryRepository(context.db);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
history.listRevisions({
|
||||||
|
projectId: "project-1",
|
||||||
|
limit: 2,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
revisions: [
|
||||||
|
{
|
||||||
|
revisionId: third.revision.revisionId,
|
||||||
|
changeSetId: third.revision.changeSetId,
|
||||||
|
revisionNumber: 3,
|
||||||
|
createdAtIso: third.revision.createdAtIso,
|
||||||
|
actorId: null,
|
||||||
|
source: "undo",
|
||||||
|
description: "Anzahl zurücknehmen",
|
||||||
|
commandType: "circuit-device-row.update",
|
||||||
|
payloadSchemaVersion: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionId: second.revision.revisionId,
|
||||||
|
changeSetId: second.revision.changeSetId,
|
||||||
|
revisionNumber: 2,
|
||||||
|
createdAtIso: second.revision.createdAtIso,
|
||||||
|
actorId: null,
|
||||||
|
source: "user",
|
||||||
|
description: "Anzahl ändern",
|
||||||
|
commandType: "circuit-device-row.update",
|
||||||
|
payloadSchemaVersion: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextBeforeRevision: 2,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
history.listRevisions({
|
||||||
|
projectId: "project-1",
|
||||||
|
limit: 2,
|
||||||
|
beforeRevision: 2,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
revisions: [
|
||||||
|
{
|
||||||
|
revisionId: first.revision.revisionId,
|
||||||
|
changeSetId: first.revision.changeSetId,
|
||||||
|
revisionNumber: 1,
|
||||||
|
createdAtIso: first.revision.createdAtIso,
|
||||||
|
actorId: "planner-1",
|
||||||
|
source: "user",
|
||||||
|
description: "Stromkreis umbenennen",
|
||||||
|
commandType: "circuit.update",
|
||||||
|
payloadSchemaVersion: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextBeforeRevision: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists an empty timeline and rejects invalid page inputs", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const history = new ProjectHistoryRepository(context.db);
|
||||||
|
assert.deepEqual(
|
||||||
|
history.listRevisions({
|
||||||
|
projectId: "project-1",
|
||||||
|
limit: 25,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 0,
|
||||||
|
revisions: [],
|
||||||
|
nextBeforeRevision: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
history.listRevisions({
|
||||||
|
projectId: "missing",
|
||||||
|
limit: 25,
|
||||||
|
}),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
history.listRevisions({
|
||||||
|
projectId: "project-1",
|
||||||
|
limit: 0,
|
||||||
|
}),
|
||||||
|
/between 1 and 100/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
history.listRevisions({
|
||||||
|
projectId: "project-1",
|
||||||
|
limit: 25,
|
||||||
|
beforeRevision: 0,
|
||||||
|
}),
|
||||||
|
/positive integer/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("persists project-wide undo and redo stacks across repository instances", () => {
|
it("persists project-wide undo and redo stacks across repository instances", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
ProjectRevisionConflictError,
|
ProjectRevisionConflictError,
|
||||||
ProjectRevisionRepository,
|
ProjectRevisionRepository,
|
||||||
} from "../src/db/repositories/project-revision.repository.js";
|
} from "../src/db/repositories/project-revision.repository.js";
|
||||||
|
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
|
||||||
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
function createTestDatabase(): DatabaseContext {
|
||||||
@@ -157,4 +159,117 @@ describe("project revision repository", () => {
|
|||||||
context.close();
|
context.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("captures and retains automatic snapshots without deleting named snapshots", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const revisions = new ProjectRevisionRepository(context.db);
|
||||||
|
const snapshots = new ProjectSnapshotRepository(context.db);
|
||||||
|
const named = snapshots.createNamed({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: "Automatisch · Revision 25",
|
||||||
|
});
|
||||||
|
assert.ok(named);
|
||||||
|
|
||||||
|
for (
|
||||||
|
let expectedRevision = 0;
|
||||||
|
expectedRevision < 325;
|
||||||
|
expectedRevision += 1
|
||||||
|
) {
|
||||||
|
appendTestRevision(revisions, expectedRevision);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = context.db
|
||||||
|
.select({
|
||||||
|
id: projectSnapshots.id,
|
||||||
|
kind: projectSnapshots.kind,
|
||||||
|
name: projectSnapshots.name,
|
||||||
|
sourceRevision: projectSnapshots.sourceRevision,
|
||||||
|
createdByActorId: projectSnapshots.createdByActorId,
|
||||||
|
})
|
||||||
|
.from(projectSnapshots)
|
||||||
|
.all();
|
||||||
|
const automatic = stored
|
||||||
|
.filter((snapshot) => snapshot.kind === "automatic")
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.sourceRevision - right.sourceRevision
|
||||||
|
);
|
||||||
|
assert.equal(automatic.length, 12);
|
||||||
|
assert.deepEqual(
|
||||||
|
automatic.map((snapshot) => snapshot.sourceRevision),
|
||||||
|
[50, 75, 100, 125, 150, 175, 200, 225, 250, 275, 300, 325]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
automatic.every(
|
||||||
|
(snapshot) => snapshot.createdByActorId === "test-user"
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
automatic.some(
|
||||||
|
(snapshot) =>
|
||||||
|
snapshot.name === "Automatisch · Revision 25"
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
stored.filter((snapshot) => snapshot.kind === "named"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: named.id,
|
||||||
|
kind: "named",
|
||||||
|
name: "Automatisch · Revision 25",
|
||||||
|
sourceRevision: 0,
|
||||||
|
createdByActorId: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back revision 25 when automatic snapshot persistence fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectRevisionRepository(context.db);
|
||||||
|
for (
|
||||||
|
let expectedRevision = 0;
|
||||||
|
expectedRevision < 24;
|
||||||
|
expectedRevision += 1
|
||||||
|
) {
|
||||||
|
appendTestRevision(repository, expectedRevision);
|
||||||
|
}
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_automatic_project_snapshot
|
||||||
|
BEFORE INSERT ON project_snapshots
|
||||||
|
WHEN NEW.kind = 'automatic'
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced automatic snapshot failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => appendTestRevision(repository, 24),
|
||||||
|
/forced automatic snapshot failure/
|
||||||
|
);
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 24);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
24
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectChangeSets).all().length,
|
||||||
|
24
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectSnapshots).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
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 { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
|
||||||
|
import { updateProjectSettings } from "../src/frontend/utils/api.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: "Testprojekt",
|
||||||
|
singlePhaseVoltageV: 230,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettings(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select({
|
||||||
|
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||||
|
currentRevision: projects.currentRevision,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project settings project-command repository", () => {
|
||||||
|
it("validates settings commands and sends the expected revision from the frontend", async () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createProjectSettingsUpdateProjectCommand({
|
||||||
|
singlePhaseVoltageV: 0,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
}),
|
||||||
|
/invalid voltages/
|
||||||
|
);
|
||||||
|
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 updateProjectSettings("project-1", 7, {
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1",
|
||||||
|
body: {
|
||||||
|
expectedRevision: 7,
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates both voltages and supports persisted undo and redo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectSettingsProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const history = new ProjectHistoryRepository(context.db);
|
||||||
|
const command = createProjectSettingsUpdateProjectCommand({
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
});
|
||||||
|
const forward = repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getSettings(context), {
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
currentRevision: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(forward.inverse.payload, {
|
||||||
|
singlePhaseVoltageV: 230,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const undoStep = history.getNextCommand("project-1", "undo");
|
||||||
|
assert.ok(undoStep);
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: undoStep.changeSetId,
|
||||||
|
command: forward.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getSettings(context), {
|
||||||
|
singlePhaseVoltageV: 230,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
currentRevision: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const redoStep = history.getNextCommand("project-1", "redo");
|
||||||
|
assert.ok(redoStep);
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: redoStep.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getSettings(context), {
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
currentRevision: 3,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects no-op, invalid project and stale revision without partial writes", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectSettingsProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectSettingsUpdateProjectCommand({
|
||||||
|
singlePhaseVoltageV: 230,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/did not change/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "missing",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectSettingsUpdateProjectCommand({
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/Project not found/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectSettingsUpdateProjectCommand({
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
ProjectRevisionConflictError
|
||||||
|
);
|
||||||
|
assert.deepEqual(getSettings(context), {
|
||||||
|
singlePhaseVoltageV: 230,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
currentRevision: 0,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back both values and revision after a late history failure", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_project_settings_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced project settings history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new ProjectSettingsProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectSettingsUpdateProjectCommand({
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/forced project settings history failure/
|
||||||
|
);
|
||||||
|
assert.deepEqual(getSettings(context), {
|
||||||
|
singlePhaseVoltageV: 230,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
currentRevision: 0,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
automaticProjectSnapshotIntervalRevisions,
|
||||||
|
automaticProjectSnapshotRetentionCount,
|
||||||
|
shouldCaptureAutomaticProjectSnapshot,
|
||||||
|
} from "../src/domain/models/project-snapshot-policy.model.js";
|
||||||
|
|
||||||
|
describe("automatic project snapshot policy", () => {
|
||||||
|
it("captures every 25 revisions and retains twelve automatic snapshots", () => {
|
||||||
|
assert.equal(automaticProjectSnapshotIntervalRevisions, 25);
|
||||||
|
assert.equal(automaticProjectSnapshotRetentionCount, 12);
|
||||||
|
assert.equal(
|
||||||
|
shouldCaptureAutomaticProjectSnapshot(24, null),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldCaptureAutomaticProjectSnapshot(25, null),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldCaptureAutomaticProjectSnapshot(49, 25),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldCaptureAutomaticProjectSnapshot(50, 25),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid or inconsistent revision inputs", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => shouldCaptureAutomaticProjectSnapshot(-1, null),
|
||||||
|
/non-negative integer/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => shouldCaptureAutomaticProjectSnapshot(25, 26),
|
||||||
|
/exceeds current revision/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { floors } from "../src/db/schema/floors.js";
|
||||||
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
|
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { rooms } from "../src/db/schema/rooms.js";
|
||||||
|
import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
|
||||||
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.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: "Snapshot-Projekt",
|
||||||
|
singlePhaseVoltageV: 230,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const board = new DistributionBoardRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const list = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(eq(circuitLists.distributionBoardId, board.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(list);
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, list.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
context.db
|
||||||
|
.insert(floors)
|
||||||
|
.values({
|
||||||
|
id: "floor-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "EG",
|
||||||
|
sortOrder: 10,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(rooms)
|
||||||
|
.values({
|
||||||
|
id: "room-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
floorId: "floor-1",
|
||||||
|
roomNumber: "001",
|
||||||
|
roomName: "Technik",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values({
|
||||||
|
id: "device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Pumpe",
|
||||||
|
displayName: "Pumpe",
|
||||||
|
phaseType: "three_phase",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 2.5,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.9,
|
||||||
|
voltageV: 400,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: list.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Technik",
|
||||||
|
sortOrder: 10,
|
||||||
|
voltage: 230,
|
||||||
|
isReserve: 0,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "device-1",
|
||||||
|
roomId: "room-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Pumpe",
|
||||||
|
displayName: "Pumpe lokal",
|
||||||
|
phaseType: "three_phase",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 2.5,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.9,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project snapshot repository", () => {
|
||||||
|
it("captures, hashes and lists a complete named logical snapshot", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectSnapshotRepository(context.db);
|
||||||
|
const created = repository.createNamed({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: " Vor Ausschreibung ",
|
||||||
|
description: " Sicherer Stand ",
|
||||||
|
actorId: "planner-1",
|
||||||
|
});
|
||||||
|
assert.ok(created);
|
||||||
|
assert.equal(created.sourceRevision, 0);
|
||||||
|
assert.equal(created.schemaVersion, 1);
|
||||||
|
assert.equal(created.name, "Vor Ausschreibung");
|
||||||
|
assert.equal(created.description, "Sicherer Stand");
|
||||||
|
assert.equal(created.createdByActorId, "planner-1");
|
||||||
|
assert.match(created.payloadSha256, /^[a-f0-9]{64}$/);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select({ currentRevision: projects.currentRevision })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.get()?.currentRevision,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const persisted = context.db
|
||||||
|
.select()
|
||||||
|
.from(projectSnapshots)
|
||||||
|
.where(eq(projectSnapshots.id, created.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(persisted);
|
||||||
|
assert.equal(
|
||||||
|
crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(persisted.payloadJson)
|
||||||
|
.digest("hex"),
|
||||||
|
persisted.payloadSha256
|
||||||
|
);
|
||||||
|
const state = deserializeProjectStateSnapshot(
|
||||||
|
persisted.payloadJson
|
||||||
|
);
|
||||||
|
assert.equal(state.project.id, "project-1");
|
||||||
|
assert.equal(state.distributionBoards.length, 1);
|
||||||
|
assert.equal(state.circuitLists.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
state.circuitSections.length,
|
||||||
|
context.db.select().from(circuitSections).all().length
|
||||||
|
);
|
||||||
|
assert.equal(state.circuits.length, 1);
|
||||||
|
assert.equal(state.circuits[0].deviceRows.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
state.circuits[0].deviceRows[0].linkedProjectDeviceId,
|
||||||
|
"device-1"
|
||||||
|
);
|
||||||
|
assert.equal(state.projectDevices.length, 1);
|
||||||
|
assert.equal(state.floors.length, 1);
|
||||||
|
assert.equal(state.rooms.length, 1);
|
||||||
|
assert.deepEqual(repository.listByProject("project-1"), [
|
||||||
|
created,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale revisions and duplicate names without partial rows", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectSnapshotRepository(context.db);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.createNamed({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
name: "Stale",
|
||||||
|
}),
|
||||||
|
ProjectRevisionConflictError
|
||||||
|
);
|
||||||
|
const created = repository.createNamed({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: "Freigabe",
|
||||||
|
});
|
||||||
|
assert.ok(created);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.createNamed({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: " Freigabe ",
|
||||||
|
}),
|
||||||
|
ProjectSnapshotNameConflictError
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectSnapshots).all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes unknown projects from empty snapshot lists", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectSnapshotRepository(context.db);
|
||||||
|
assert.deepEqual(repository.listByProject("project-1"), []);
|
||||||
|
assert.equal(repository.listByProject("missing"), null);
|
||||||
|
assert.equal(
|
||||||
|
repository.createNamed({
|
||||||
|
projectId: "missing",
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: "Unbekannt",
|
||||||
|
}),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies stored payload integrity before preparing a restore", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectSnapshotRepository(context.db);
|
||||||
|
const snapshot = repository.createNamed({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: "Freigabe",
|
||||||
|
});
|
||||||
|
assert.ok(snapshot);
|
||||||
|
const prepared = repository.prepareRestore(
|
||||||
|
"project-1",
|
||||||
|
snapshot.id
|
||||||
|
);
|
||||||
|
assert.ok(prepared);
|
||||||
|
assert.equal(
|
||||||
|
prepared.command.payload.targetState.project.id,
|
||||||
|
"project-1"
|
||||||
|
);
|
||||||
|
context.db
|
||||||
|
.update(projectSnapshots)
|
||||||
|
.set({ payloadJson: `${JSON.stringify({})}\n` })
|
||||||
|
.where(eq(projectSnapshots.id, snapshot.id))
|
||||||
|
.run();
|
||||||
|
assert.throws(
|
||||||
|
() => repository.prepareRestore("project-1", snapshot.id),
|
||||||
|
/checksum verification failed/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
repository.prepareRestore("project-1", "missing"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
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 { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js";
|
||||||
|
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
|
||||||
|
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
|
||||||
|
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
|
||||||
|
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
|
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||||
|
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.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 { 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";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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";
|
||||||
|
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { consumers } from "../src/db/schema/consumers.js";
|
||||||
|
import { floors } from "../src/db/schema/floors.js";
|
||||||
|
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
|
||||||
|
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
|
||||||
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { rooms } from "../src/db/schema/rooms.js";
|
||||||
|
import { ProjectStateConflictError } from "../src/domain/errors/project-state-conflict.error.js";
|
||||||
|
import { ProjectCommandService } from "../src/domain/services/project-command.service.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: "Ausgangsprojekt" })
|
||||||
|
.run();
|
||||||
|
const board = new DistributionBoardRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const list = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(eq(circuitLists.distributionBoardId, board.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(list);
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, list.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
context.db
|
||||||
|
.insert(floors)
|
||||||
|
.values({
|
||||||
|
id: "floor-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "EG",
|
||||||
|
sortOrder: 10,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(rooms)
|
||||||
|
.values({
|
||||||
|
id: "room-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
floorId: "floor-1",
|
||||||
|
roomNumber: "001",
|
||||||
|
roomName: "Technik",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values({
|
||||||
|
id: "device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Pumpe",
|
||||||
|
displayName: "Pumpe",
|
||||||
|
phaseType: "three_phase",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 2.5,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: list.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Ausgang",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: 0,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "device-1",
|
||||||
|
roomId: "room-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Pumpe",
|
||||||
|
displayName: "Pumpe",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 2.5,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(context: DatabaseContext) {
|
||||||
|
return new ProjectCommandService(
|
||||||
|
new CircuitProjectCommandRepository(context.db),
|
||||||
|
new CircuitDeviceRowProjectCommandRepository(context.db),
|
||||||
|
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
|
||||||
|
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
|
||||||
|
new CircuitStructureProjectCommandRepository(context.db),
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(context.db),
|
||||||
|
new CircuitSectionReorderProjectCommandRepository(context.db),
|
||||||
|
new CircuitSectionRenumberProjectCommandRepository(context.db),
|
||||||
|
new ProjectDeviceStructureProjectCommandRepository(context.db),
|
||||||
|
new ProjectDeviceProjectCommandRepository(context.db),
|
||||||
|
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
||||||
|
new ProjectSettingsProjectCommandRepository(context.db),
|
||||||
|
new ProjectStateRestoreCommandRepository(context.db),
|
||||||
|
new ProjectHistoryRepository(context.db)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeCompleteProjectState(context: DatabaseContext) {
|
||||||
|
context.db
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
name: "Geänderter Stand",
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
})
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.update(circuits)
|
||||||
|
.set({ displayName: "Geänderter Stromkreis" })
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(floors)
|
||||||
|
.values({
|
||||||
|
id: "floor-2",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "OG",
|
||||||
|
sortOrder: 20,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(rooms)
|
||||||
|
.values({
|
||||||
|
id: "room-2",
|
||||||
|
projectId: "project-1",
|
||||||
|
floorId: "floor-2",
|
||||||
|
roomNumber: "101",
|
||||||
|
roomName: "Büro",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values({
|
||||||
|
id: "device-2",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.05,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
new DistributionBoardRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-02");
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedUpgradeOnlyData(context: DatabaseContext) {
|
||||||
|
const circuit = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(circuit);
|
||||||
|
context.db
|
||||||
|
.insert(consumers)
|
||||||
|
.values({
|
||||||
|
id: "consumer-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
distributionBoardId:
|
||||||
|
context.db.select().from(circuitLists).get()!
|
||||||
|
.distributionBoardId,
|
||||||
|
circuitListId: circuit.circuitListId,
|
||||||
|
projectDeviceId: "device-1",
|
||||||
|
roomId: "room-1",
|
||||||
|
name: "Legacy-Pumpe",
|
||||||
|
quantity: 1,
|
||||||
|
installedPowerPerUnitKw: 2.5,
|
||||||
|
demandFactor: 0.8,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(legacyConsumerCircuitMigrations)
|
||||||
|
.values({
|
||||||
|
consumerId: "consumer-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
circuitDeviceRowId: "row-1",
|
||||||
|
circuitListId: circuit.circuitListId,
|
||||||
|
createdAtIso: "2026-07-25T00:00:00.000Z",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(legacyConsumerMigrationReports)
|
||||||
|
.values({
|
||||||
|
id: "report-1",
|
||||||
|
circuitListId: circuit.circuitListId,
|
||||||
|
legacyConsumerCount: 1,
|
||||||
|
createdCircuitCount: 1,
|
||||||
|
createdDeviceRowCount: 1,
|
||||||
|
duplicateGroupedCount: 0,
|
||||||
|
generatedIdentifierCount: 0,
|
||||||
|
unassignedRowCount: 0,
|
||||||
|
warningsJson: "[]",
|
||||||
|
generatedIdentifiersJson: "[]",
|
||||||
|
duplicateGroupsJson: "[]",
|
||||||
|
createdAtIso: "2026-07-25T00:00:00.000Z",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project state restore command", () => {
|
||||||
|
it("restores a complete snapshot as a revision and supports undo and redo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const snapshots = new ProjectSnapshotRepository(context.db);
|
||||||
|
seedUpgradeOnlyData(context);
|
||||||
|
const snapshot = snapshots.createNamed({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: "Freigabe",
|
||||||
|
});
|
||||||
|
assert.ok(snapshot);
|
||||||
|
const original = readProjectStateSnapshot(context.db, "project-1");
|
||||||
|
assert.ok(original);
|
||||||
|
|
||||||
|
changeCompleteProjectState(context);
|
||||||
|
const changed = readProjectStateSnapshot(context.db, "project-1");
|
||||||
|
assert.ok(changed);
|
||||||
|
assert.notEqual(changed.payloadSha256, original.payloadSha256);
|
||||||
|
|
||||||
|
const prepared = snapshots.prepareRestore(
|
||||||
|
"project-1",
|
||||||
|
snapshot.id
|
||||||
|
);
|
||||||
|
assert.ok(prepared);
|
||||||
|
const service = createService(context);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: prepared.command,
|
||||||
|
}),
|
||||||
|
/stored server snapshot/
|
||||||
|
);
|
||||||
|
const restored = service.executeRestore({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
description: "Snapshot wiederherstellen",
|
||||||
|
command: prepared.command,
|
||||||
|
});
|
||||||
|
assert.equal(restored.revision.revisionNumber, 1);
|
||||||
|
assert.equal(restored.history.undoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
readProjectStateSnapshot(context.db, "project-1")?.payloadSha256,
|
||||||
|
original.payloadSha256
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectSnapshots).all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(legacyConsumerCircuitMigrations)
|
||||||
|
.all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(legacyConsumerMigrationReports)
|
||||||
|
.all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select({ roomId: consumers.roomId })
|
||||||
|
.from(consumers)
|
||||||
|
.where(eq(consumers.id, "consumer-1"))
|
||||||
|
.get()?.roomId,
|
||||||
|
"room-1"
|
||||||
|
);
|
||||||
|
|
||||||
|
const undone = service.undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(undone.revision.revisionNumber, 2);
|
||||||
|
assert.equal(undone.history.redoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
readProjectStateSnapshot(context.db, "project-1")?.payloadSha256,
|
||||||
|
changed.payloadSha256
|
||||||
|
);
|
||||||
|
|
||||||
|
const redone = service.redo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
});
|
||||||
|
assert.equal(redone.revision.revisionNumber, 3);
|
||||||
|
assert.equal(
|
||||||
|
readProjectStateSnapshot(context.db, "project-1")?.payloadSha256,
|
||||||
|
original.payloadSha256
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({ source: projectRevisions.source })
|
||||||
|
.from(projectRevisions)
|
||||||
|
.all()
|
||||||
|
.map((entry) => entry.source),
|
||||||
|
["restore", "undo", "redo"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a prepared restore after an unversioned state change", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const snapshots = new ProjectSnapshotRepository(context.db);
|
||||||
|
const snapshot = snapshots.createNamed({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: "Freigabe",
|
||||||
|
});
|
||||||
|
assert.ok(snapshot);
|
||||||
|
changeCompleteProjectState(context);
|
||||||
|
const prepared = snapshots.prepareRestore(
|
||||||
|
"project-1",
|
||||||
|
snapshot.id
|
||||||
|
);
|
||||||
|
assert.ok(prepared);
|
||||||
|
context.db
|
||||||
|
.update(projects)
|
||||||
|
.set({ name: "Noch neuer" })
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createService(context).executeRestore({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: prepared.command,
|
||||||
|
}),
|
||||||
|
ProjectStateConflictError
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select({ name: projects.name })
|
||||||
|
.from(projects)
|
||||||
|
.get()?.name,
|
||||||
|
"Noch neuer"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the complete replacement after a late history failure", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const snapshots = new ProjectSnapshotRepository(context.db);
|
||||||
|
const snapshot = snapshots.createNamed({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: "Freigabe",
|
||||||
|
});
|
||||||
|
assert.ok(snapshot);
|
||||||
|
changeCompleteProjectState(context);
|
||||||
|
const changed = readProjectStateSnapshot(context.db, "project-1");
|
||||||
|
const prepared = snapshots.prepareRestore(
|
||||||
|
"project-1",
|
||||||
|
snapshot.id
|
||||||
|
);
|
||||||
|
assert.ok(changed);
|
||||||
|
assert.ok(prepared);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_restore_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced restore history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createService(context).executeRestore({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: prepared.command,
|
||||||
|
}),
|
||||||
|
/forced restore history failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
readProjectStateSnapshot(context.db, "project-1")?.payloadSha256,
|
||||||
|
changed.payloadSha256
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select({ currentRevision: projects.currentRevision })
|
||||||
|
.from(projects)
|
||||||
|
.get()?.currentRevision,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
deserializeProjectStateSnapshot,
|
||||||
|
parseProjectStateSnapshot,
|
||||||
|
serializeProjectStateSnapshot,
|
||||||
|
} from "../src/domain/models/project-state-snapshot.model.js";
|
||||||
|
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
|
||||||
|
|
||||||
|
function minimalSnapshot() {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
project: {
|
||||||
|
id: "project-1",
|
||||||
|
name: "Projekt",
|
||||||
|
singlePhaseVoltageV: 230,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
},
|
||||||
|
distributionBoards: [],
|
||||||
|
circuitLists: [],
|
||||||
|
circuitSections: [],
|
||||||
|
circuits: [],
|
||||||
|
projectDevices: [],
|
||||||
|
floors: [],
|
||||||
|
rooms: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project state snapshot model", () => {
|
||||||
|
it("round-trips a versioned logical project state", () => {
|
||||||
|
const snapshot = parseProjectStateSnapshot(minimalSnapshot());
|
||||||
|
assert.deepEqual(
|
||||||
|
deserializeProjectStateSnapshot(
|
||||||
|
serializeProjectStateSnapshot(snapshot)
|
||||||
|
),
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate ids and cross-project ownership", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parseProjectStateSnapshot({
|
||||||
|
...minimalSnapshot(),
|
||||||
|
distributionBoards: [
|
||||||
|
{
|
||||||
|
id: "board-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "UV 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "board-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "UV 2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/duplicate distribution board ids/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parseProjectStateSnapshot({
|
||||||
|
...minimalSnapshot(),
|
||||||
|
floors: [
|
||||||
|
{
|
||||||
|
id: "floor-1",
|
||||||
|
projectId: "project-2",
|
||||||
|
name: "EG",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/floor belongs to a different project/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid references and inconsistent reserve circuits", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parseProjectStateSnapshot({
|
||||||
|
...minimalSnapshot(),
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
id: "room-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
floorId: "missing",
|
||||||
|
roomNumber: "001",
|
||||||
|
roomName: "Technik",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/room floor reference is invalid/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parseProjectStateSnapshot({
|
||||||
|
...minimalSnapshot(),
|
||||||
|
circuits: [
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: "missing",
|
||||||
|
sectionId: "missing",
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
protectionType: null,
|
||||||
|
protectionRatedCurrent: null,
|
||||||
|
protectionCharacteristic: null,
|
||||||
|
cableType: null,
|
||||||
|
cableCrossSection: null,
|
||||||
|
cableLength: null,
|
||||||
|
rcdAssignment: null,
|
||||||
|
terminalDesignation: null,
|
||||||
|
voltage: null,
|
||||||
|
controlRequirement: null,
|
||||||
|
status: null,
|
||||||
|
isReserve: true,
|
||||||
|
remark: null,
|
||||||
|
deviceRows: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/circuit list reference is invalid/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("named project snapshot request", () => {
|
||||||
|
it("normalizes bounded snapshot metadata", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
createNamedProjectSnapshotSchema.parse({
|
||||||
|
expectedRevision: 12,
|
||||||
|
name: " Freigabe ",
|
||||||
|
description: " Stand vor Ausschreibung ",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
expectedRevision: 12,
|
||||||
|
name: "Freigabe",
|
||||||
|
description: "Stand vor Ausschreibung",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale-shaped and excessive input", () => {
|
||||||
|
for (const input of [
|
||||||
|
{ expectedRevision: -1, name: "Stand" },
|
||||||
|
{ expectedRevision: 0, name: " " },
|
||||||
|
{ expectedRevision: 0, name: "x".repeat(101) },
|
||||||
|
{
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: "Stand",
|
||||||
|
description: "x".repeat(501),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedRevision: 0,
|
||||||
|
name: "Stand",
|
||||||
|
unexpected: true,
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
assert.equal(
|
||||||
|
createNamedProjectSnapshotSchema.safeParse(input).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
|
||||||
|
import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js";
|
||||||
|
import {
|
||||||
|
createNamedProjectSnapshot,
|
||||||
|
getProjectHistory,
|
||||||
|
listProjectRevisions,
|
||||||
|
listProjectSnapshots,
|
||||||
|
redoProjectCommand,
|
||||||
|
restoreProjectSnapshot,
|
||||||
|
undoProjectCommand,
|
||||||
|
} from "../src/frontend/utils/api.js";
|
||||||
|
import {
|
||||||
|
getProjectRevisionDescription,
|
||||||
|
getProjectRevisionSourceLabel,
|
||||||
|
getProjectSnapshotKindLabel,
|
||||||
|
mergeProjectRevisionPages,
|
||||||
|
} from "../src/frontend/utils/project-version-history.js";
|
||||||
|
|
||||||
|
function revision(
|
||||||
|
revisionNumber: number,
|
||||||
|
overrides: Partial<ProjectRevisionSummaryDto> = {}
|
||||||
|
): ProjectRevisionSummaryDto {
|
||||||
|
return {
|
||||||
|
revisionId: `revision-${revisionNumber}`,
|
||||||
|
changeSetId: `change-${revisionNumber}`,
|
||||||
|
revisionNumber,
|
||||||
|
createdAtIso: "2026-07-25T12:00:00.000Z",
|
||||||
|
actorId: null,
|
||||||
|
source: "user",
|
||||||
|
description: null,
|
||||||
|
commandType: "circuit.update",
|
||||||
|
payloadSchemaVersion: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project version history presentation", () => {
|
||||||
|
it("renders a compact collapsed German entry point", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
createElement(ProjectVersionHistory, {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 42,
|
||||||
|
onProjectStateChange: () => undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.match(markup, /Versionen und Sicherungspunkte/);
|
||||||
|
assert.match(markup, /Aktuelle Revision 42/);
|
||||||
|
assert.match(markup, /Rückgängig/);
|
||||||
|
assert.match(markup, /Wiederholen/);
|
||||||
|
assert.match(markup, /Anzeigen/);
|
||||||
|
assert.doesNotMatch(markup, /Stand speichern/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses German source and command descriptions", () => {
|
||||||
|
assert.equal(getProjectRevisionSourceLabel("user"), "Bearbeitung");
|
||||||
|
assert.equal(getProjectRevisionSourceLabel("undo"), "Rückgängig");
|
||||||
|
assert.equal(getProjectRevisionSourceLabel("redo"), "Wiederholt");
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionSourceLabel("restore"),
|
||||||
|
"Wiederherstellung"
|
||||||
|
);
|
||||||
|
assert.equal(getProjectRevisionSourceLabel("migration"), "Migration");
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(revision(1)),
|
||||||
|
"Stromkreis bearbeitet"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(2, { description: " Eigene Beschreibung " })
|
||||||
|
),
|
||||||
|
"Eigene Beschreibung"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(3, { commandType: "future.command" })
|
||||||
|
),
|
||||||
|
"future.command"
|
||||||
|
);
|
||||||
|
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
|
||||||
|
assert.equal(
|
||||||
|
getProjectSnapshotKindLabel("automatic"),
|
||||||
|
"Automatisch"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges paginated revisions without duplicates in descending order", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeProjectRevisionPages(
|
||||||
|
[revision(5), revision(4)],
|
||||||
|
[revision(4), revision(3)]
|
||||||
|
).map((entry) => entry.revisionNumber),
|
||||||
|
[5, 4, 3]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project version history API", () => {
|
||||||
|
it("uses the revision-safe timeline and snapshot routes", async () => {
|
||||||
|
const requests: Array<{
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
body: unknown;
|
||||||
|
}> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
requests.push({
|
||||||
|
url: String(input),
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
body: init?.body ? JSON.parse(String(init.body)) : null,
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getProjectHistory("project-1");
|
||||||
|
await listProjectRevisions("project-1", {
|
||||||
|
limit: 10,
|
||||||
|
beforeRevision: 21,
|
||||||
|
});
|
||||||
|
await listProjectSnapshots("project-1");
|
||||||
|
await createNamedProjectSnapshot(
|
||||||
|
"project-1",
|
||||||
|
22,
|
||||||
|
"Planfreigabe",
|
||||||
|
"Vor Ausführung"
|
||||||
|
);
|
||||||
|
await restoreProjectSnapshot("project-1", "snapshot-1", 22);
|
||||||
|
await undoProjectCommand("project-1", 23);
|
||||||
|
await redoProjectCommand("project-1", 24);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/history",
|
||||||
|
method: "GET",
|
||||||
|
body: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/history/revisions?limit=10&beforeRevision=21",
|
||||||
|
method: "GET",
|
||||||
|
body: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/snapshots",
|
||||||
|
method: "GET",
|
||||||
|
body: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/snapshots",
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
expectedRevision: 22,
|
||||||
|
name: "Planfreigabe",
|
||||||
|
description: "Vor Ausführung",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/snapshots/snapshot-1/restore",
|
||||||
|
method: "POST",
|
||||||
|
body: { expectedRevision: 22 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/history/undo",
|
||||||
|
method: "POST",
|
||||||
|
body: { expectedRevision: 23 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/history/redo",
|
||||||
|
method: "POST",
|
||||||
|
body: { expectedRevision: 24 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user