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
|
||||
|
||||
The editor's visible undo/redo stack remains session-local while move, reorder
|
||||
and renumber operations are being migrated. Existing Circuit and
|
||||
CircuitDeviceRow cell edits plus standalone insertion/deletion already execute
|
||||
persistent project commands; their toolbar undo/redo actions use the
|
||||
project-wide history endpoints. Insertions use client-generated stable UUIDs,
|
||||
and deletion undo restores the same ids from complete server snapshots. The
|
||||
tree response supplies the optimistic `currentRevision`; obsolete direct field
|
||||
PATCH, structure POST and CircuitDeviceRow DELETE routes are removed.
|
||||
Reloading the page does not yet reconstruct the visible editor stack.
|
||||
The editor reads undo/redo eligibility from the project-wide server history on
|
||||
initial load and after every tree reload. Undo/redo therefore remains available
|
||||
after a page refresh or application restart. All currently supported Circuit
|
||||
and CircuitDeviceRow writes execute persistent project commands. Applying a
|
||||
sorted view across multiple sections is one atomic `circuit.reorder-sections`
|
||||
command and one undo step. Explicit renumbering uses the collision-safe
|
||||
`circuit.renumber-section` command and is never triggered implicitly.
|
||||
Immutable revision metadata is available through the paginated
|
||||
`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
|
||||
placeholder target circuit are persisted. The latter stores the complete empty
|
||||
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
|
||||
returned project revision. ProjectDevice synchronization/disconnect API and UI
|
||||
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:
|
||||
|
||||
@@ -287,7 +313,7 @@ Users must be able to override sizing suggestions.
|
||||
- Never edit an already applied migration.
|
||||
- Back up an existing database before applying a new migration.
|
||||
- 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.
|
||||
- Keep synchronous SQLite transaction behavior inside persistence adapters.
|
||||
- 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
|
||||
|
||||
- complete persistent project command coverage and the undo/redo UI cutover
|
||||
- named logical snapshots and restore
|
||||
- Revit/CSV/IFCGUID round-trip
|
||||
- full electrical sizing
|
||||
- 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
|
||||
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
||||
persistentes Undo/Redo, Projektversionen und der Revit-/IFCGUID-Datenaustausch sind
|
||||
noch nicht implementiert.
|
||||
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
||||
implementiert.
|
||||
|
||||
## Unterstützter Arbeitsablauf
|
||||
|
||||
@@ -18,7 +18,9 @@ noch nicht implementiert.
|
||||
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
||||
- komplette Stromkreisblöcke filtern und sortieren
|
||||
- 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
|
||||
|
||||
|
||||
@@ -14,6 +14,16 @@ this value for optimistic concurrency checks.
|
||||
- `GET /projects/:projectId/history`
|
||||
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
|
||||
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`
|
||||
- executes a supported versioned command as a new user revision
|
||||
- body: `{ "expectedRevision": 0, "command": { ... } }`
|
||||
@@ -22,23 +32,50 @@ this value for optimistic concurrency checks.
|
||||
- body: `{ "expectedRevision": 1 }`
|
||||
- executes the eligible inverse or forward command as a new auditable
|
||||
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`,
|
||||
`circuit.delete`, `circuit-device-row.update`,
|
||||
`circuit-device-row.insert`, `circuit-device-row.delete`,
|
||||
`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.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
|
||||
with stable ids; delete commands include the expected parent identity. Circuit
|
||||
snapshots contain zero, one or multiple complete device rows. Move commands
|
||||
contain each row's expected and target circuit plus its exact expected and
|
||||
target sort order. This makes deletion and moves undoable without generating
|
||||
replacement identities or renumbering circuits. Existing Circuit and
|
||||
CircuitDeviceRow cell edits plus standalone insert/delete actions in the editor
|
||||
consume these endpoints. Move, reorder and renumber UI paths are still being
|
||||
migrated.
|
||||
CircuitDeviceRow cell edits, standalone insert/delete actions, device-row
|
||||
moves, reorders and explicit renumbering in the editor consume these endpoints.
|
||||
|
||||
`circuit-device-row.move` targets existing circuits in the same circuit list.
|
||||
`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`;
|
||||
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
|
||||
for every circuit in the section. Assignments contain expected and target
|
||||
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
|
||||
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:
|
||||
|
||||
```json
|
||||
@@ -127,7 +181,14 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
### Distribution Board Setup
|
||||
|
||||
- `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
|
||||
|
||||
@@ -169,82 +230,12 @@ Response sketch:
|
||||
- `GET /circuit-sections/:sectionId/next-identifier`
|
||||
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
||||
|
||||
Circuit and device-row field updates, standalone insertions and standalone
|
||||
deletions are available only as the corresponding versioned commands through
|
||||
Circuit and device-row field updates, standalone insertions/deletions, single
|
||||
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,
|
||||
structure POST or CircuitDeviceRow DELETE routes. The direct
|
||||
`DELETE /circuits/:circuitId` route remains temporarily for the inverse of the
|
||||
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" }
|
||||
]
|
||||
}
|
||||
```
|
||||
structure POST, move, reorder, renumber, identifier-restore, Circuit DELETE or
|
||||
CircuitDeviceRow DELETE routes.
|
||||
|
||||
## Removed Legacy Endpoints
|
||||
|
||||
|
||||
@@ -122,13 +122,16 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
||||
|
||||
## Undo/Redo
|
||||
|
||||
Undo/redo wraps editor operations as command objects and reloads the tree after
|
||||
each command. Existing Circuit and device-row cell edits plus standalone
|
||||
insert/delete actions execute persistent project commands; their toolbar
|
||||
undo/redo calls the project-wide server history. New entities receive stable
|
||||
UUIDs before insertion, and deletion undo restores those same ids. Move,
|
||||
reorder and renumber operations still use their existing direct API writes and
|
||||
session-local inverse callbacks during the cutover.
|
||||
Editor operations execute persistent project commands and reload the tree after
|
||||
each command. On initial load and every reload, the editor reads undo/redo
|
||||
eligibility from the project-wide server history and verifies that tree and
|
||||
history belong to the same project revision. Undo/redo therefore remains
|
||||
available after a page refresh. New entities receive stable UUIDs before
|
||||
insertion, and deletion undo restores those same ids. Device-row moves and
|
||||
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:
|
||||
|
||||
@@ -139,8 +142,7 @@ Covered operations include:
|
||||
- renumber and identifier update flows
|
||||
- apply sorted order
|
||||
|
||||
Current limitations:
|
||||
|
||||
- the visible editor stack is still session-local and is empty after reload
|
||||
- move, reorder and renumber editor actions are not connected to the persistent
|
||||
command boundary yet
|
||||
The editor toolbar exposes availability through its Undo/Redo buttons. The
|
||||
project page provides the same persistent project-wide controls in the header
|
||||
of its version card, including after a reload. Named restore points and the
|
||||
revision timeline remain inside the expandable part of that card.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Circuit List Editor Known Limitations
|
||||
|
||||
- Existing Circuit and CircuitDeviceRow cell edits plus standalone
|
||||
insert/delete actions use persistent project commands and project-wide
|
||||
toolbar undo/redo. The editor's visible stack is still session-local, is
|
||||
empty after reload and still contains direct move/reorder/renumber operations
|
||||
during the cutover.
|
||||
- Server-side project revisions and persistent undo/redo eligibility exist for
|
||||
the command types documented in `circuit-list-editor-api.md`; complete editor
|
||||
command coverage and history reconstruction are not complete.
|
||||
- Named logical project snapshots and restore are not implemented yet.
|
||||
- All currently supported Circuit and CircuitDeviceRow editor writes use
|
||||
persistent project commands and project-wide toolbar undo/redo, including
|
||||
moves, atomic multi-section reorders and explicit renumbering. Undo/redo
|
||||
eligibility is restored from server history after a page reload.
|
||||
- The project page exposes the paginated revision timeline; the circuit editor
|
||||
itself currently exposes only the next eligible Undo/Redo actions.
|
||||
- Named and automatic logical project snapshots can be listed and restored
|
||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||
created every 25 revisions and only their newest 12 entries are retained.
|
||||
- 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.
|
||||
- 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
|
||||
Viewport soweit möglich wieder her.
|
||||
|
||||
Der sichtbare React-Historiestapel ist während des schrittweisen Cutovers noch
|
||||
sitzungslokal. Das Datenmodell besitzt
|
||||
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
|
||||
Der Editor besitzt keinen sitzungslokalen Undo-/Redo-Stapel mehr. Beim initialen
|
||||
Laden und nach jedem Tree-Reload liest er den persistenten History-Status und
|
||||
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
|
||||
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
|
||||
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
|
||||
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
|
||||
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
|
||||
angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne
|
||||
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
|
||||
Revision gemeinsam committen beziehungsweise zurückrollen können.
|
||||
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten
|
||||
Circuit-, Gerätezeilen-, Projektgeräte- und Projekteinstellungsänderungen
|
||||
verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes
|
||||
inverses Kommando, Revision und Historienstapel gemeinsam committen
|
||||
beziehungsweise zurückrollen.
|
||||
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
|
||||
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
|
||||
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
|
||||
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
|
||||
`GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt
|
||||
die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
|
||||
öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind
|
||||
`circuit-device-row.insert` und `circuit-device-row.delete` als atomare
|
||||
Strukturkommandos vorhanden. 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
|
||||
`GET /api/projects/:projectId/history` lesbar. Die unveränderliche,
|
||||
absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über
|
||||
`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler
|
||||
Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über
|
||||
öffentliche Command-, Undo- und Redo-Endpunkte aus.
|
||||
`circuit-device-row.insert` und `circuit-device-row.delete` sind atomare
|
||||
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.
|
||||
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
|
||||
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
|
||||
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
|
||||
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST- und Gerätezeilen-DELETE-
|
||||
Endpunkte sind entfernt. Move-, Reorder- und Renumber-UI-Pfade verbleiben
|
||||
vorerst im sichtbaren sitzungslokalen Stack; nach einem Reload wird dieser noch
|
||||
nicht aus der Serverhistorie rekonstruiert.
|
||||
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und
|
||||
Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves,
|
||||
Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die
|
||||
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
|
||||
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
||||
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
|
||||
Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen
|
||||
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
|
||||
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
|
||||
ä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
|
||||
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
|
||||
ä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
|
||||
Verknüpfen als atomaren Mehrzeilen-Command. Jede betroffene Zeile enthält den
|
||||
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
|
||||
exakt die vorherigen lokalen Werte wieder her. Die Synchronisieren- und
|
||||
Trennen-Endpunkte sowie die Projektseite verwenden diesen Command mit
|
||||
optimistischer Revisionsprüfung. Das dortige Rückgängig löst die persistente
|
||||
projektweite Historie aus; separate direkte Restore-/Reconnect-Schreibwege
|
||||
optimistischer Revisionsprüfung. Rückgängig/Wiederholen erfolgt dort
|
||||
einheitlich über die persistente projektweite Historie; der frühere
|
||||
sitzungslokale Spezial-Undo und separate direkte Restore-/Reconnect-Schreibwege
|
||||
existieren nicht mehr.
|
||||
`project-device.update` versioniert Änderungen an den kanonischen
|
||||
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
|
||||
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
||||
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
|
||||
|
||||
@@ -167,8 +229,6 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
||||
|
||||
## Noch nicht unterstützt
|
||||
|
||||
- vollständiges persistentes Undo/Redo, sichtbare Historie nach Reload und
|
||||
benannte Projektstände
|
||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||
- Revit-/CSV-/IFCGUID-Round-trip
|
||||
- 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.
|
||||
|
||||
## Dev-only visual test helper (multi-device circuit)
|
||||
## Visual verification
|
||||
|
||||
Add one extra manual device row to an existing circuit:
|
||||
|
||||
```bash
|
||||
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>
|
||||
```
|
||||
Manual device rows and multi-device circuits can be created directly in the
|
||||
circuit-list editor. The former development scripts for direct database writes
|
||||
were removed because they bypassed project revisions and persistent undo/redo.
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
- `GET /api/projects/:projectId/history` exposes current revision, stack depths
|
||||
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`
|
||||
and `circuit-device-row.update` envelopes without coupling the domain service
|
||||
to SQLite
|
||||
@@ -194,12 +236,25 @@ Completed foundation:
|
||||
empty target-circuit snapshot; forward creation and all row moves share one
|
||||
transaction, while undo restores the source positions and deletes only an
|
||||
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
|
||||
stores exact expected/target sort positions; its inverse changes no
|
||||
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
|
||||
stores every expected/target equipment identifier, uses collision-safe
|
||||
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
|
||||
as one atomic multi-row operation; complete expected/target sync snapshots
|
||||
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
|
||||
ids; delete undo restores links only from complete unchanged disconnected-row
|
||||
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
|
||||
deliberately added to history; the generic versioned envelope is complete
|
||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||
@@ -224,7 +280,6 @@ Remaining constraints before completing persistent history:
|
||||
|
||||
## Deferred Decisions
|
||||
|
||||
- exact snapshot frequency and retention policy
|
||||
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
||||
- branch visualization after undo followed by new edits
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
@@ -358,14 +358,23 @@ Tasks:
|
||||
- [x] add immutable append-only server-side change-set storage with versioned
|
||||
forward/inverse payloads
|
||||
- [x] add optimistic checks when appending a revision
|
||||
- route domain writes and history records through one shared transaction
|
||||
- define concrete serializable domain command descriptions and executors
|
||||
- [x] route supported domain writes and history records through one shared transaction
|
||||
- [x] define concrete serializable domain command descriptions and executors
|
||||
outside React
|
||||
- [x] persist undo/redo eligibility on the server
|
||||
- [x] expose public command, undo and redo endpoints for the first supported
|
||||
update command types
|
||||
- add named and periodic logical project snapshots
|
||||
- restore a historical snapshot as a new revision
|
||||
- [x] expose a cursor-paginated, read-only revision metadata timeline without
|
||||
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
|
||||
|
||||
Implemented foundation:
|
||||
@@ -387,6 +396,18 @@ Implemented foundation:
|
||||
late-failure rollback coverage
|
||||
- a read-only project history endpoint exposes revision, stack depths and top
|
||||
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
|
||||
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
|
||||
field updates
|
||||
@@ -424,6 +445,12 @@ Implemented foundation:
|
||||
- ProjectDevice synchronization and disconnect endpoints also require
|
||||
`expectedRevision`; their UI undo uses project-wide persistent history and
|
||||
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
|
||||
in one SQLite transaction
|
||||
- a stale expected revision produces no history writes
|
||||
@@ -431,10 +458,13 @@ Implemented foundation:
|
||||
and rollback after a forced late persistence failure
|
||||
|
||||
ProjectDevice CRUD, synchronization and disconnect on the project page and
|
||||
Circuit/CircuitDeviceRow field/insert/delete actions in the circuit-list editor
|
||||
are connected to this history boundary. Move, reorder and renumber UI paths are
|
||||
not yet connected; the editor's visible undo/redo stack therefore remains
|
||||
session-local and is not reconstructed after reload.
|
||||
Circuit/CircuitDeviceRow field/insert/delete actions, device-row moves and
|
||||
circuit reorders in the circuit-list editor are connected to this history
|
||||
boundary. Applying sorted order across multiple sections is one atomic command.
|
||||
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:
|
||||
|
||||
|
||||
+3
-5
@@ -14,16 +14,14 @@
|
||||
"build:api": "tsc -p tsconfig.json",
|
||||
"build:web": "next build",
|
||||
"start": "node dist/server/index.js",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-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-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-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:migrate": "drizzle-kit migrate",
|
||||
"db:backup": "tsx scripts/db-backup.ts",
|
||||
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
||||
"db:backfill:sections": "tsx scripts/db-backfill-sections.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"
|
||||
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"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,
|
||||
listRooms,
|
||||
synchronizeProjectDeviceRows,
|
||||
undoProjectCommand,
|
||||
updateProjectDevice,
|
||||
updateProjectSettings,
|
||||
} from "../../../frontend/utils/api";
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
projectDeviceSyncFields,
|
||||
type ProjectDeviceSyncField,
|
||||
} from "../../../shared/constants/project-device-sync-fields";
|
||||
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
|
||||
|
||||
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||
name: "Technischer Name",
|
||||
@@ -55,11 +55,6 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||
remark: "Bemerkung",
|
||||
};
|
||||
|
||||
type ProjectDeviceUndoState = {
|
||||
projectDeviceId: string;
|
||||
revisionNumber: number;
|
||||
};
|
||||
|
||||
const emptyProjectDevice: CreateProjectDeviceInput = {
|
||||
name: "",
|
||||
displayName: "",
|
||||
@@ -116,7 +111,6 @@ export default function ProjectDetailPage() {
|
||||
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
||||
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
||||
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
||||
const [projectDeviceUndo, setProjectDeviceUndo] = useState<ProjectDeviceUndoState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -179,20 +173,27 @@ export default function ProjectDetailPage() {
|
||||
setProject((current) =>
|
||||
current ? { ...current, currentRevision } : current
|
||||
);
|
||||
setProjectDeviceUndo(null);
|
||||
}
|
||||
|
||||
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!projectId || !boardName.trim()) {
|
||||
if (!projectId || !project || !boardName.trim()) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createDistributionBoard(projectId, boardName.trim());
|
||||
setBoards((current) => [...current, created]);
|
||||
const result = await createDistributionBoard(
|
||||
projectId,
|
||||
boardName.trim(),
|
||||
project.currentRevision
|
||||
);
|
||||
setBoards((current) => [
|
||||
...current,
|
||||
result.distributionBoard,
|
||||
]);
|
||||
setCircuitLists(await listCircuitLists(projectId));
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
setBoardName("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
|
||||
@@ -244,17 +245,21 @@ export default function ProjectDetailPage() {
|
||||
}
|
||||
|
||||
async function handleSaveProjectSettings() {
|
||||
if (!projectId) {
|
||||
if (!projectId || !project) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateProjectSettings(projectId, {
|
||||
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||||
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||||
});
|
||||
setProject(updated);
|
||||
const result = await updateProjectSettings(
|
||||
projectId,
|
||||
project.currentRevision,
|
||||
{
|
||||
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||||
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||||
}
|
||||
);
|
||||
setProject(result.project);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden.");
|
||||
} finally {
|
||||
@@ -381,7 +386,6 @@ export default function ProjectDetailPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setProjectDeviceUndo(null);
|
||||
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
|
||||
setSyncPreview(preview);
|
||||
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
||||
@@ -433,10 +437,6 @@ export default function ProjectDetailPage() {
|
||||
setSelectedSyncRowIds(
|
||||
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
|
||||
);
|
||||
setProjectDeviceUndo({
|
||||
projectDeviceId: syncPreview.projectDevice.id,
|
||||
revisionNumber: result.revision.revisionNumber,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
|
||||
} finally {
|
||||
@@ -469,10 +469,6 @@ export default function ProjectDetailPage() {
|
||||
.map((row) => row.rowId)
|
||||
);
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
setProjectDeviceUndo({
|
||||
projectDeviceId: syncPreview.projectDevice.id,
|
||||
revisionNumber: result.revision.revisionNumber,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
|
||||
} 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() {
|
||||
if (!projectId || !project || !selectedGlobalDeviceId) {
|
||||
return;
|
||||
@@ -598,7 +560,14 @@ export default function ProjectDetailPage() {
|
||||
className="btn btn-primary w-100"
|
||||
type="button"
|
||||
onClick={handleSaveProjectSettings}
|
||||
disabled={isSaving}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!project ||
|
||||
(Number(singlePhaseVoltageV) ===
|
||||
project.singlePhaseVoltageV &&
|
||||
Number(threePhaseVoltageV) ===
|
||||
project.threePhaseVoltageV)
|
||||
}
|
||||
>
|
||||
Projekteigenschaften speichern
|
||||
</button>
|
||||
@@ -608,6 +577,16 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
</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">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">Neue Verteilung</div>
|
||||
@@ -1141,16 +1120,6 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
className="btn btn-primary"
|
||||
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,
|
||||
"tag": "0013_project_history_stacks",
|
||||
"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 {
|
||||
assertCircuitSectionReorderProjectCommand,
|
||||
circuitSectionReorderCommandType,
|
||||
createCircuitSectionReorderProjectCommand,
|
||||
type CircuitSectionReorderAssignment,
|
||||
type CircuitSectionReorderProjectCommand,
|
||||
} 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 {
|
||||
CircuitSectionReorderProjectCommandStore,
|
||||
ExecuteCircuitSectionReorderCommandInput,
|
||||
@@ -14,106 +24,49 @@ import { circuits } from "../schema/circuits.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
|
||||
type ReorderHistoryCommand =
|
||||
| CircuitSectionReorderProjectCommand
|
||||
| CircuitSectionsReorderProjectCommand;
|
||||
|
||||
export class CircuitSectionReorderProjectCommandRepository
|
||||
implements CircuitSectionReorderProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitSectionReorderCommandInput) {
|
||||
assertCircuitSectionReorderProjectCommand(input.command);
|
||||
this.assertSupportedCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const section = tx
|
||||
.select({
|
||||
id: circuitSections.id,
|
||||
circuitListId: circuitSections.circuitListId,
|
||||
projectId: circuitLists.projectId,
|
||||
})
|
||||
.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."
|
||||
const commandSections = this.getCommandSections(input.command);
|
||||
const inverseSections = commandSections.map((section) => {
|
||||
this.assertCompleteCurrentSection(
|
||||
tx,
|
||||
input.projectId,
|
||||
section
|
||||
);
|
||||
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
|
||||
.select({
|
||||
id: circuits.id,
|
||||
circuitListId: circuits.circuitListId,
|
||||
sortOrder: circuits.sortOrder,
|
||||
})
|
||||
.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
|
||||
)
|
||||
const inverse =
|
||||
input.command.type === circuitSectionReorderCommandType
|
||||
? createCircuitSectionReorderProjectCommand(
|
||||
inverseSections[0].sectionId,
|
||||
inverseSections[0].assignments
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Circuit changed during section reorder execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
: createCircuitSectionsReorderProjectCommand(
|
||||
inverseSections
|
||||
);
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
@@ -132,4 +85,125 @@ export class CircuitSectionReorderProjectCommandRepository
|
||||
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 { and, asc, eq } from "drizzle-orm";
|
||||
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||
import { db } from "../client.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
|
||||
export function createDefaultCircuitSectionValues(circuitListId: string) {
|
||||
return [
|
||||
{ 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) => ({
|
||||
return defaultCircuitSectionDefinitions.map((entry) => ({
|
||||
id: crypto.randomUUID(),
|
||||
circuitListId,
|
||||
...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,
|
||||
input: ApplyProjectHistoryTransitionInput
|
||||
) {
|
||||
if (input.source === "user") {
|
||||
if (input.source === "user" || input.source === "restore") {
|
||||
database
|
||||
.delete(projectHistoryStackEntries)
|
||||
.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 type {
|
||||
ProjectHistoryCommand,
|
||||
ProjectHistoryDirection,
|
||||
ListProjectRevisionsInput,
|
||||
ProjectRevisionPage,
|
||||
ProjectHistoryState,
|
||||
ProjectHistoryStore,
|
||||
} 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 { projectChangeSets } from "../schema/project-change-sets.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(
|
||||
projectId: string,
|
||||
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 { projectRevisions } from "../schema/project-revisions.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { captureAutomaticProjectSnapshot } from "./automatic-project-snapshot.persistence.js";
|
||||
|
||||
export function appendProjectRevision(
|
||||
database: AppDatabase,
|
||||
@@ -73,6 +74,13 @@ export function appendProjectRevision(
|
||||
})
|
||||
.run();
|
||||
|
||||
captureAutomaticProjectSnapshot(database, {
|
||||
projectId: input.projectId,
|
||||
currentRevision: revisionNumber,
|
||||
createdAtIso,
|
||||
actorId: input.actorId,
|
||||
});
|
||||
|
||||
return {
|
||||
revisionId,
|
||||
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 type {
|
||||
CreateProjectInput,
|
||||
UpdateProjectSettingsInput,
|
||||
} from "../../shared/validation/project-structure.schemas.js";
|
||||
|
||||
export class ProjectRepository {
|
||||
@@ -30,16 +29,6 @@ export class ProjectRepository {
|
||||
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) {
|
||||
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 { CircuitSectionsReorderProjectCommand } from "../models/circuit-sections-reorder-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
@@ -11,12 +12,16 @@ export interface ExecuteCircuitSectionReorderCommandInput {
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitSectionReorderProjectCommand;
|
||||
command:
|
||||
| CircuitSectionReorderProjectCommand
|
||||
| CircuitSectionsReorderProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExecutedCircuitSectionReorderCommand {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitSectionReorderProjectCommand;
|
||||
inverse:
|
||||
| CircuitSectionReorderProjectCommand
|
||||
| CircuitSectionsReorderProjectCommand;
|
||||
}
|
||||
|
||||
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(
|
||||
input: ExecuteUserProjectCommandInput
|
||||
): ExecutedProjectCommand;
|
||||
executeRestore(
|
||||
input: ExecuteUserProjectCommandInput
|
||||
): ExecutedProjectCommand;
|
||||
undo(
|
||||
input: ExecuteProjectHistoryCommandInput
|
||||
): ExecutedProjectCommand;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SerializedProjectCommand } from "../models/project-command.model.js";
|
||||
import type { ProjectRevisionSource } from "./project-revision.store.js";
|
||||
|
||||
export type ProjectHistoryDirection = "undo" | "redo";
|
||||
|
||||
@@ -16,8 +17,36 @@ export interface ProjectHistoryCommand {
|
||||
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 {
|
||||
getState(projectId: string): ProjectHistoryState | null;
|
||||
listRevisions(
|
||||
input: ListProjectRevisionsInput
|
||||
): ProjectRevisionPage | null;
|
||||
getNextCommand(
|
||||
projectId: string,
|
||||
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,
|
||||
circuitSectionReorderCommandType,
|
||||
} from "../models/circuit-section-reorder-project-command.model.js";
|
||||
import {
|
||||
assertCircuitSectionsReorderProjectCommand,
|
||||
circuitSectionsReorderCommandType,
|
||||
} from "../models/circuit-sections-reorder-project-command.model.js";
|
||||
import {
|
||||
assertCircuitSectionRenumberProjectCommand,
|
||||
circuitSectionRenumberCommandType,
|
||||
@@ -33,10 +37,24 @@ import {
|
||||
circuitDeleteCommandType,
|
||||
circuitInsertCommandType,
|
||||
} from "../models/circuit-structure-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardDeleteProjectCommand,
|
||||
assertDistributionBoardInsertProjectCommand,
|
||||
distributionBoardDeleteCommandType,
|
||||
distributionBoardInsertCommandType,
|
||||
} from "../models/distribution-board-structure-project-command.model.js";
|
||||
import {
|
||||
assertSerializedProjectCommand,
|
||||
type SerializedProjectCommand,
|
||||
} 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 {
|
||||
assertProjectDeviceRowSyncProjectCommand,
|
||||
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 { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-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 {
|
||||
ExecuteProjectHistoryCommandInput,
|
||||
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 { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.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 {
|
||||
projectId: string;
|
||||
@@ -80,7 +101,7 @@ interface DispatchProjectCommandInput {
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: SerializedProjectCommand;
|
||||
command: SerializedProjectCommand<unknown>;
|
||||
}
|
||||
|
||||
export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
@@ -90,11 +111,14 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
|
||||
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
|
||||
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
|
||||
private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore,
|
||||
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
|
||||
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
|
||||
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
|
||||
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
|
||||
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
|
||||
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
|
||||
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
|
||||
private readonly historyStore: ProjectHistoryStore
|
||||
) {}
|
||||
|
||||
@@ -103,6 +127,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
): ExecutedProjectCommand {
|
||||
assertExpectedRevision(input.expectedRevision);
|
||||
assertSerializedProjectCommand(input.command);
|
||||
if (input.command.type === projectStateRestoreCommandType) {
|
||||
throw new Error(
|
||||
"Project state restore commands require a stored server snapshot."
|
||||
);
|
||||
}
|
||||
return this.dispatch({
|
||||
...input,
|
||||
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(
|
||||
input: ExecuteProjectHistoryCommandInput
|
||||
): ExecutedProjectCommand {
|
||||
@@ -218,6 +259,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).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: {
|
||||
assertCircuitSectionReorderProjectCommand(input.command);
|
||||
return this.circuitSectionReorderStore.execute({
|
||||
@@ -225,6 +280,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitSectionsReorderCommandType: {
|
||||
assertCircuitSectionsReorderProjectCommand(input.command);
|
||||
return this.circuitSectionReorderStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitSectionRenumberCommandType: {
|
||||
assertCircuitSectionRenumberProjectCommand(input.command);
|
||||
return this.circuitSectionRenumberStore.execute({
|
||||
@@ -260,6 +322,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).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:
|
||||
throw new Error(
|
||||
`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;
|
||||
}
|
||||
|
||||
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 {
|
||||
schemaVersion: number;
|
||||
type: string;
|
||||
@@ -38,6 +87,11 @@ export interface DistributionBoardDto {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DistributionBoardCommandResultDto
|
||||
extends ProjectCommandResultDto {
|
||||
distributionBoard: DistributionBoardDto;
|
||||
}
|
||||
|
||||
export interface CircuitListDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
|
||||
+181
-54
@@ -5,6 +5,7 @@ import type {
|
||||
CreateRoomInput,
|
||||
CreateGlobalDeviceInput,
|
||||
DistributionBoardDto,
|
||||
DistributionBoardCommandResultDto,
|
||||
FloorDto,
|
||||
GlobalDeviceDto,
|
||||
ProjectCommandDto,
|
||||
@@ -12,6 +13,10 @@ import type {
|
||||
ProjectDeviceCommandResultDto,
|
||||
ProjectDeviceDto,
|
||||
ProjectHistoryStateDto,
|
||||
ProjectRevisionPageDto,
|
||||
ProjectSnapshotMetadataDto,
|
||||
ProjectSnapshotRestoreResultDto,
|
||||
ProjectSettingsCommandResultDto,
|
||||
ProjectDeviceSyncCommandResultDto,
|
||||
ProjectDeviceSyncPreviewDto,
|
||||
ProjectDto,
|
||||
@@ -33,6 +38,18 @@ import type {
|
||||
import type {
|
||||
CircuitSnapshot,
|
||||
} 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> {
|
||||
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(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
@@ -134,23 +207,34 @@ export function createProject(name: string) {
|
||||
|
||||
export function updateProjectSettings(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
input: { singlePhaseVoltageV: number; threePhaseVoltageV: number }
|
||||
) {
|
||||
return request<ProjectDto>(`/api/projects/${projectId}`, {
|
||||
return request<ProjectSettingsCommandResultDto>(
|
||||
`/api/projects/${projectId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
body: JSON.stringify({ expectedRevision, ...input }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function listDistributionBoards(projectId: string) {
|
||||
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
|
||||
}
|
||||
|
||||
export function createDistributionBoard(projectId: string, name: string) {
|
||||
return request<DistributionBoardDto>(`/api/projects/${projectId}/distribution-boards`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
export function createDistributionBoard(
|
||||
projectId: string,
|
||||
name: string,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<DistributionBoardCommandResultDto>(
|
||||
`/api/projects/${projectId}/distribution-boards`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return request<{ sectionId: string; nextIdentifier: string }>(
|
||||
`/api/circuit-sections/${sectionId}/next-identifier`
|
||||
@@ -275,52 +353,101 @@ export function deleteCircuitDeviceRowCommand(
|
||||
);
|
||||
}
|
||||
|
||||
export function moveCircuitDeviceRowById(
|
||||
rowId: string,
|
||||
input: { targetCircuitId?: string; targetSectionId?: string; createNewCircuit?: boolean }
|
||||
export function moveCircuitDeviceRowsCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
moves: CircuitDeviceRowMoveAssignment[],
|
||||
description: string
|
||||
) {
|
||||
return request(`/api/circuit-device-rows/${rowId}/move`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
}
|
||||
schemaVersion: 1,
|
||||
type: "circuit-device-row.move",
|
||||
payload: { moves },
|
||||
},
|
||||
description
|
||||
);
|
||||
}
|
||||
|
||||
export function renumberCircuitSection(sectionId: string) {
|
||||
return request(`/api/circuit-sections/${sectionId}/renumber`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
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 }>
|
||||
export function moveCircuitDeviceRowsToNewCircuitCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
targetCircuit: CircuitSnapshot,
|
||||
moves: CircuitDeviceRowMoveAssignment[],
|
||||
description: string
|
||||
) {
|
||||
return request(`/api/circuit-sections/${sectionId}/equipment-identifiers`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ identifiers }),
|
||||
});
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
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) {
|
||||
|
||||
@@ -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 { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-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 { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
|
||||
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js";
|
||||
import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js";
|
||||
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";
|
||||
|
||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||
@@ -21,6 +24,8 @@ export const circuitDeviceRowMoveProjectCommandStore =
|
||||
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||
export const circuitStructureProjectCommandStore =
|
||||
new CircuitStructureProjectCommandRepository(db);
|
||||
export const distributionBoardStructureProjectCommandStore =
|
||||
new DistributionBoardStructureProjectCommandRepository(db);
|
||||
export const circuitSectionReorderProjectCommandStore =
|
||||
new CircuitSectionReorderProjectCommandRepository(db);
|
||||
export const circuitSectionRenumberProjectCommandStore =
|
||||
@@ -31,6 +36,10 @@ export const projectDeviceProjectCommandStore =
|
||||
new ProjectDeviceProjectCommandRepository(db);
|
||||
export const projectDeviceRowSyncProjectCommandStore =
|
||||
new ProjectDeviceRowSyncProjectCommandRepository(db);
|
||||
export const projectSettingsProjectCommandStore =
|
||||
new ProjectSettingsProjectCommandRepository(db);
|
||||
export const projectStateRestoreCommandStore =
|
||||
new ProjectStateRestoreCommandRepository(db);
|
||||
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||
export const projectCommandService = new ProjectCommandService(
|
||||
circuitProjectCommandStore,
|
||||
@@ -38,10 +47,13 @@ export const projectCommandService = new ProjectCommandService(
|
||||
circuitDeviceRowStructureProjectCommandStore,
|
||||
circuitDeviceRowMoveProjectCommandStore,
|
||||
circuitStructureProjectCommandStore,
|
||||
distributionBoardStructureProjectCommandStore,
|
||||
circuitSectionReorderProjectCommandStore,
|
||||
circuitSectionRenumberProjectCommandStore,
|
||||
projectDeviceStructureProjectCommandStore,
|
||||
projectDeviceProjectCommandStore,
|
||||
projectDeviceRowSyncProjectCommandStore,
|
||||
projectSettingsProjectCommandStore,
|
||||
projectStateRestoreCommandStore,
|
||||
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 { circuitWriteService } from "../composition/circuit-write-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." });
|
||||
}
|
||||
}
|
||||
import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
|
||||
|
||||
export async function getNextCircuitIdentifier(req: Request, res: Response) {
|
||||
const { sectionId } = req.params;
|
||||
@@ -21,7 +7,8 @@ export async function getNextCircuitIdentifier(req: Request, res: Response) {
|
||||
return res.status(400).json({ error: "Invalid sectionId" });
|
||||
}
|
||||
try {
|
||||
const nextIdentifier = await circuitWriteService.getNextIdentifier(sectionId);
|
||||
const nextIdentifier =
|
||||
await circuitNumberingService.getNextIdentifier(sectionId);
|
||||
return res.json({ sectionId, nextIdentifier });
|
||||
} catch (error) {
|
||||
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 { db } from "../../db/client.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 { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
||||
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() });
|
||||
}
|
||||
|
||||
const board = distributionBoardRepository.createWithCircuitListAndDefaultSections(
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
projectId,
|
||||
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 { listProjectRevisionsQuerySchema } from "../../shared/validation/project-history.schemas.js";
|
||||
import { projectHistoryStore } from "../composition/project-command-stores.js";
|
||||
|
||||
export function getProjectHistory(req: Request, res: Response) {
|
||||
@@ -13,3 +14,23 @@ export function getProjectHistory(req: Request, res: Response) {
|
||||
}
|
||||
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,
|
||||
updateProjectSettingsSchema,
|
||||
} 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();
|
||||
|
||||
@@ -44,10 +47,20 @@ export async function updateProjectSettings(req: Request, res: Response) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
await projectRepository.updateSettings(projectId, parsed.data);
|
||||
const row = await projectRepository.findById(projectId);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
const { expectedRevision, ...settings } = parsed.data;
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
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 { circuitDeviceRowRouter } from "./routes/circuit-device-row.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 { projectDeviceRouter } from "./routes/project-device.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", circuitRouter);
|
||||
app.use("/api", circuitDeviceRowRouter);
|
||||
app.use("/api", circuitSectionRouter);
|
||||
app.use("/api/global-devices", globalDeviceRouter);
|
||||
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 {
|
||||
deleteCircuit,
|
||||
getNextCircuitIdentifier,
|
||||
} from "../controllers/circuit.controller.js";
|
||||
|
||||
export const circuitRouter = Router();
|
||||
|
||||
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
|
||||
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 { createRoom, listRoomsByProject } from "../controllers/room.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 {
|
||||
executeProjectCommand,
|
||||
redoProjectCommand,
|
||||
undoProjectCommand,
|
||||
} from "../controllers/project-command.controller.js";
|
||||
import {
|
||||
createNamedProjectSnapshot,
|
||||
listProjectSnapshots,
|
||||
restoreProjectSnapshot,
|
||||
} from "../controllers/project-snapshot.controller.js";
|
||||
|
||||
export const projectRouter = Router();
|
||||
|
||||
@@ -26,9 +34,16 @@ projectRouter.get("/", listProjects);
|
||||
projectRouter.post("/", createProject);
|
||||
projectRouter.get("/:projectId", getProject);
|
||||
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
|
||||
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.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||
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 { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -6,14 +7,20 @@ export const createProjectSchema = z.object({
|
||||
threePhaseVoltageV: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
export const updateProjectSettingsSchema = z.object({
|
||||
singlePhaseVoltageV: z.number().positive(),
|
||||
threePhaseVoltageV: z.number().positive(),
|
||||
});
|
||||
export const updateProjectSettingsSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
singlePhaseVoltageV: z.number().positive(),
|
||||
threePhaseVoltageV: z.number().positive(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const createDistributionBoardSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
export const createDistributionBoardSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
name: z.string().trim().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const createFloorSchema = z.object({
|
||||
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 { projects } from "../src/db/schema/projects.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 {
|
||||
context: DatabaseContext;
|
||||
sectionId: string;
|
||||
secondSectionId: string;
|
||||
foreignSectionId: string;
|
||||
}
|
||||
|
||||
@@ -44,17 +46,20 @@ function createTestDatabase(): TestFixture {
|
||||
"project-2",
|
||||
"UV-02"
|
||||
);
|
||||
const section = context.db
|
||||
const projectSections = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, board.id))
|
||||
.get();
|
||||
.all();
|
||||
const section = projectSections[0];
|
||||
const secondSection = projectSections[1];
|
||||
const foreignSection = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||
.get();
|
||||
assert.ok(section);
|
||||
assert.ok(secondSection);
|
||||
assert.ok(foreignSection);
|
||||
|
||||
context.db
|
||||
@@ -107,6 +112,7 @@ function createTestDatabase(): TestFixture {
|
||||
return {
|
||||
context,
|
||||
sectionId: section.id,
|
||||
secondSectionId: secondSection.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", () => {
|
||||
const fixture = createTestDatabase();
|
||||
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 { 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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { 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 { circuitSections } from "../src/db/schema/circuit-sections.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 { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-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 { 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 { 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 { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
|
||||
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
@@ -95,11 +104,14 @@ function createService(context: DatabaseContext) {
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -764,6 +776,34 @@ describe("project command service", () => {
|
||||
{ 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 {
|
||||
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", () => {
|
||||
const context = createTestDatabase();
|
||||
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", () => {
|
||||
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", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
ProjectRevisionConflictError,
|
||||
ProjectRevisionRepository,
|
||||
} 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 { 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";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
@@ -157,4 +159,117 @@ describe("project revision repository", () => {
|
||||
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