Compare commits

...

82 Commits

Author SHA1 Message Date
jappel cfd4778305 Add Revit requirements handoff 2026-07-29 19:26:39 +02:00
jappel fac6c9350f Document first release baseline 2026-07-29 19:23:07 +02:00
jappel 9ea081179d Add distribution power summaries 2026-07-29 19:02:18 +02:00
jappel dcb303284c Describe snapshot source revisions 2026-07-29 11:52:26 +02:00
jappel 602ae6da0b Compact circuit grid columns 2026-07-29 11:12:24 +02:00
jappel 1b2ca84258 Preserve focus inside grid editors 2026-07-29 11:02:30 +02:00
jappel 096ba8aa1c Standardize phase type labels 2026-07-29 10:46:00 +02:00
jappel 084103bf54 Derive device voltages from project settings 2026-07-29 09:53:58 +02:00
jappel b1a11397b3 Add configurable distribution supply types 2026-07-29 09:31:20 +02:00
jappel 194bc9c0b1 Add project overview import 2026-07-29 08:36:57 +02:00
jappel 0180f768ac Improve editor totals and formatting 2026-07-29 08:26:03 +02:00
jappel a04c2c04d4 Document current product backlog 2026-07-28 22:28:05 +02:00
jappel edf245a21c Use full-width editor device drawer 2026-07-28 20:43:08 +02:00
jappel feec814dc5 Add project transfer controls 2026-07-28 19:36:26 +02:00
jappel da8115fe1d Add portable project transfer API 2026-07-28 18:34:57 +02:00
jappel 5fa2a701dc Streamline project management UI 2026-07-28 18:30:44 +02:00
jappel d77cfbeb49 Add versioned project settings modal 2026-07-28 18:16:22 +02:00
jappel e1fc81a083 Complete persistence foundation 2026-07-26 12:33:13 +02:00
jappel d7b98de56a Inject application database contexts 2026-07-26 12:30:42 +02:00
jappel 859fa4a42a Decouple legacy migration service 2026-07-26 12:11:05 +02:00
jappel c1be85e408 Decouple runtime domain services 2026-07-26 12:04:50 +02:00
jappel 0cedeaa959 Remove unused revision repository 2026-07-26 11:40:04 +02:00
jappel 4789e01aac Centralize snapshot restore transactions 2026-07-26 11:35:45 +02:00
jappel 214ad728cd Centralize device row update transactions 2026-07-26 11:28:06 +02:00
jappel e4d22caf09 Centralize update command transactions 2026-07-26 11:24:19 +02:00
jappel 0f306d9a90 Reuse command transaction boundary 2026-07-26 11:21:01 +02:00
jappel 266bdb4749 Centralize structural command transactions 2026-07-26 11:14:24 +02:00
jappel d23e7d990c Remove obsolete repository writes 2026-07-26 11:05:26 +02:00
jappel ac465a1cc0 Persist project locations 2026-07-26 10:56:29 +02:00
jappel 1cf26a932e Persist distribution board setup 2026-07-25 23:41:27 +02:00
jappel 1a77eedaa8 Add automatic project snapshots 2026-07-25 23:27:51 +02:00
jappel 59d442593f Expose project-wide history controls 2026-07-25 23:16:46 +02:00
jappel b2763f72d5 Persist project settings updates 2026-07-25 23:08:07 +02:00
jappel d00ae30bda Expose project version history 2026-07-25 22:56:51 +02:00
jappel 384a5769ab Restore named project snapshots 2026-07-25 22:41:24 +02:00
jappel 7c670fece3 Add named project snapshots 2026-07-25 22:15:22 +02:00
jappel 5a0c9019af Expose project revision timeline 2026-07-25 21:57:03 +02:00
jappel 4b47bc2bcc Restore editor history after reload 2026-07-25 21:45:58 +02:00
jappel c45afd0981 Persist section renumbering 2026-07-25 21:30:29 +02:00
jappel abcb468807 Persist circuit reorders 2026-07-25 21:22:10 +02:00
jappel 08a2775a88 Persist device row moves 2026-07-25 21:09:02 +02:00
jappel 2eba4ea75e Persist circuit structure edits 2026-07-25 20:49:34 +02:00
jappel 76d8d59391 Persist circuit cell edits 2026-07-25 20:26:45 +02:00
jappel 3977a6e6e1 Persist project device synchronization 2026-07-24 11:47:03 +02:00
jappel e930cb75b8 Route project devices through history 2026-07-24 10:57:50 +02:00
jappel ac16cca77a Add project device structure history 2026-07-24 09:09:59 +02:00
jappel 0bc6c7372f Add project device update history 2026-07-24 08:57:57 +02:00
jappel 2668fc2f16 Add project device sync history 2026-07-24 08:47:25 +02:00
jappel 4b4603b71b Add section renumber history 2026-07-24 08:34:31 +02:00
jappel 332dfdb5d9 Add circuit reorder history 2026-07-24 08:21:32 +02:00
jappel 53282e8c7c Add placeholder move history 2026-07-23 22:39:59 +02:00
jappel 0a51703315 Add device row move history 2026-07-23 22:29:44 +02:00
jappel ca13efbfcb Add circuit history commands 2026-07-23 22:18:36 +02:00
jappel bd5f4f925f Add device row history commands 2026-07-23 22:09:53 +02:00
jappel e4c7cf06e9 Add project command API 2026-07-23 21:56:13 +02:00
jappel 1e4dd26bb8 Add persistent history stacks 2026-07-23 21:48:24 +02:00
jappel 2d0aa11d9c Add atomic device row updates 2026-07-23 21:38:20 +02:00
jappel 296cb0f1c4 Add atomic circuit update commands 2026-07-23 21:32:25 +02:00
jappel b79faed320 Add serializable project commands 2026-07-23 21:23:31 +02:00
jappel 903d977443 Add project revision foundation 2026-07-23 21:17:48 +02:00
jappel 6b6d2c2a42 Rewrite project documentation 2026-07-23 21:05:43 +02:00
jappel 30d6f1a2e1 Remove project device mirror fields 2026-07-23 20:55:26 +02:00
jappel 3fbf3ac622 Remove legacy consumer API 2026-07-23 20:00:48 +02:00
jappel 04c299e3f2 Remove legacy consumer UI 2026-07-23 19:23:53 +02:00
jappel d7ce135ac8 Verify legacy migration completion 2026-07-23 19:16:41 +02:00
jappel 030f18aeb0 Verify SQLite backups and restores 2026-07-23 19:12:14 +02:00
jappel 4d5eb22e66 Isolate legacy migration transactions 2026-07-23 19:06:30 +02:00
jappel 9be94028b3 Isolate circuit section transactions 2026-07-23 19:02:38 +02:00
jappel 91baa6c76c Isolate project device sync transactions 2026-07-23 18:57:18 +02:00
jappel cf356bbedc Make device row moves atomic 2026-07-23 18:52:40 +02:00
jappel a6837d7242 Make initial circuit creation atomic 2026-07-23 18:46:50 +02:00
jappel 30c7555c68 Isolate device row transactions 2026-07-23 18:33:22 +02:00
jappel d7fda37576 Add real SQLite transaction tests 2026-07-23 18:22:32 +02:00
jappel e51c516cf2 Plan documentation handoff after legacy cutover 2026-07-23 18:17:39 +02:00
jappel 6696bba72a Document future project history architecture 2026-07-23 18:11:09 +02:00
jappel c7e5f7a80d Make legacy migration atomic 2026-07-23 17:55:04 +02:00
jappel ff84d85eb3 Show placeholders in empty circuit lists 2026-07-23 17:49:50 +02:00
jappel b2034c7dfb Create distribution boards atomically 2026-07-23 17:45:33 +02:00
jappel 1c57df46fa Prevent stale cell update overwrites 2026-07-23 17:43:11 +02:00
jappel 0800f984bb Create circuits with rows atomically 2026-07-23 17:36:38 +02:00
jappel b0fc2b4fbf Make circuit reordering atomic 2026-07-23 17:30:40 +02:00
jappel 7d276d1139 Make device row writes atomic 2026-07-23 17:28:42 +02:00
270 changed files with 55988 additions and 8391 deletions
+145 -2
View File
@@ -2,12 +2,51 @@
## Project Goal
Build a spreadsheet-like electrical distribution board circuit list editor.
Maintain and extend a spreadsheet-like electrical distribution board circuit list editor.
The editor is used for electrical planning in execution design.
It must support circuits, device rows, project devices, drag-and-drop restructuring, stable equipment identifiers and later electrical sizing logic.
## Current Supported Architecture
- Frontend: Next.js App Router under `src/app` with reusable editor modules under `src/frontend`.
- API: Express composition starts in `src/server/index.ts`.
- Domain rules: `src/domain`.
- Persistence: SQLite/Drizzle schemas, repositories and migrations under `src/db`.
- Primary editor route:
`src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`.
- Primary editor component: `src/frontend/components/circuit-tree-editor.tsx`.
- Grid ownership, projection, insertion and safety rules live in the pure
`src/frontend/components/circuit-grid-*.ts` modules.
- Critical multi-write commands use injected transaction repositories with real
SQLite commit/rollback tests.
- All supported runtime project-command stores, including full snapshot
restoration, share
`src/db/repositories/project-command-transaction.persistence.ts` for the
atomic domain-write, revision and history transition boundary. Its applied
forward-command variant preserves derived CircuitDeviceRow override metadata.
- Low-level `appendProjectRevision` persistence is adapter-internal and tested
directly; do not reintroduce a standalone runtime revision repository.
- Runtime domain services receive narrow reader/store dependencies explicitly;
concrete SQLite repositories are instantiated only under `src/server/composition`.
- General application repositories also require an explicit `AppDatabase`;
`src/server/composition/application-repositories.ts` owns their runtime
instances, and controllers never import the global SQLite client.
- The upgrade-only legacy migration service follows the same dependency rule;
its concrete repositories are instantiated only in
`scripts/db-migrate-legacy-consumers.ts`.
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
repositories expose only active reads. Runtime writes belong in typed command
repositories; direct integration fixtures belong under `tests/support`.
The supported runtime model is Circuit-First. The former Consumer UI and API are
removed. Retained `consumers` rows, migration mappings and reports are upgrade-only
data accessed by `npm run db:migrate:legacy-consumers`; do not build application
features on them.
See `docs/current-architecture.md` for the complete module and request flow.
## Critical Domain Rules
- A circuit is not the same thing as one device row.
@@ -112,6 +151,9 @@ Renumber only when the user explicitly triggers "Renumber section".
The circuit list table should behave like a spreadsheet.
User-facing frontend text must be German unless a domain-standard technical term
is intentionally retained.
Cells show static text by default.
Inline edit starts by:
@@ -196,7 +238,69 @@ After saving, the row becomes linked to the new project device.
## Undo / Redo
Implement undo/redo for structural and destructive operations.
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
generated circuit. Complete
in-section Circuit reorders are persisted separately and change sort positions
without changing equipment identifiers. Explicit complete-section renumbering
is persisted through a separate collision-safe command and is never triggered
by sorting or moving. Project-device synchronization, disconnect and reconnect
are persisted as one atomic multi-row command with complete expected/target row
snapshots, including link and override metadata. Canonical ProjectDevice field
updates are also persisted and never synchronize linked rows implicitly.
ProjectDevice insertion/deletion preserves stable device ids. Deletion captures
complete disconnected snapshots of linked rows so undo can restore only rows
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 settings use the persistent `project.update-settings` command. Project
metadata, both voltage defaults and the enabled distribution-board supply types
change in one revision and Undo/Redo restores them together; the project PUT
route requires `expectedRevision`. The system catalog is `AV`, `SV`, `EV`,
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
cannot be disabled.
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.
Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one
persistent undo step.
Floor and room setup use `project-floor.insert` and `project-room.insert` with
complete stable snapshots. Their inverses remove only unchanged records and
reject floors with assigned rooms/distribution boards or rooms referenced by
device rows or retained upgrade data. Both POST routes require `expectedRevision` and return
the updated history state.
Required operations:
@@ -232,6 +336,45 @@ Future sizing will need:
Users must be able to override sizing suggestions.
## Persistence and Migration Rules
- SQLite is the currently supported database.
- 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 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
PostgreSQL adapter.
## Current Deferred Work
- Revit/CSV/IFCGUID round-trip
- full electrical sizing
- multi-user/PostgreSQL operation
- supported production deployment
Do not implement these while working on an unrelated phase.
## Documentation and Verification
- `README.md` is the setup entry point.
- `docs/README.md` is the documentation map.
- `docs/current-architecture.md` describes current code paths.
- `docs/spec/` contains requirements and roadmap, not proof of implementation.
- `docs/archive/` is historical and must not drive current implementation.
For a normal code change run the relevant focused tests plus:
- `npm test`
- `npm run build:api`
- `npm run build:web`
- `npm run typecheck:scripts`
- `npx tsc --noEmit -p tsconfig.next.json`
Use a concise imperative commit message for each completed, verified work package.
## Response Style for Codex
Be concise.
+92 -194
View File
@@ -1,225 +1,123 @@
# Leistungsbilanz Hauptdokumentation
# Leistungsbilanz
Diese Anwendung unterstützt die elektrische Fachplanung (TGA/ELT) bei der Erstellung und Pflege von Leistungsbilanzen und Stromkreislisten.
Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung. Im
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
Sie ist als praxisnahe Webanwendung für kleine Teams gedacht (ca. 23 gleichzeitige Nutzer), mit Schwerpunkt auf schneller tabellarischer Bearbeitung statt komplexer Enterprise-Strukturen.
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
implementiert.
## Sinn und Ziel der Anwendung
## Unterstützter Arbeitsablauf
Die Anwendung soll Planer dabei unterstützen:
- Projekte, Verteilungen, Etagen und Räume verwalten
- pro Verteilung eine Stromkreisliste mit festen Bereichen bearbeiten
- leere, einzeilige und mehrzeilige Stromkreise abbilden
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
- 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 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
1. Projekte anzulegen und zu verwalten.
2. Verteilungen pro Projekt anzulegen.
3. Verbraucher strukturiert in Stromkreislisten zu erfassen.
4. Installierte Leistung, Gleichzeitigkeitsleistung und Strom automatisch zu berechnen.
5. Gerätevorlagen global sowie projektbezogen zu verwalten und wiederzuverwenden.
6. Planungsdaten schrittweise zu vervollständigen, ohne unnötige Pflichtfeldhürden.
## Technik
Fachlich stehen folgende Begriffe im Zentrum:
- Next.js 16, React 19 und TypeScript für das Frontend
- Express 5 und Zod für die API
- SQLite, `better-sqlite3` und Drizzle ORM für die Persistenz
- eigener Spreadsheet-Grid statt eines Bootstrap-Tabellenframeworks
- Node.js-Test-Runner für Domain-, Grid- und SQLite-Integrationstests
- Projekt
- Verteilung
- Stromkreisliste
- Verbraucher/Gerät
- installierte Leistung
- Gleichzeitigkeitsfaktor
- berechnete Leistung
- Spannung (1-phasig / 3-phasig)
- Strom
## Schnellstart mit Docker
## Anforderungsbasis
Voraussetzungen:
Die fachliche Basis stammt aus [docs/electrical-load-balance-requirements-context-dump.md](docs/electrical-load-balance-requirements-context-dump.md).
- Git
- Docker Desktop mit Docker Compose
Die folgende Liste fasst die zentralen Anforderungen zusammen und zeigt den aktuellen Umsetzungsstand im Code.
```powershell
git clone <repository-url>
Set-Location leistungsbilanz-ts
docker compose up --build --detach
```
## Anforderungsliste mit Ist-Stand
Danach:
### 1) Projektverwaltung
- Anforderung: Projekte erstellen und anzeigen.
- Status: Erfüllt.
- Umsetzung: UI in `src/app/projects/page.tsx`, API in `src/server/controllers/project.controller.ts`.
- Frontend: <http://localhost:3001>
- API-Healthcheck: <http://localhost:3000/health>
### 2) Verteilungen pro Projekt
- Anforderung: Mehrere Verteilungen pro Projekt.
- Status: Erfüllt.
- Umsetzung: `src/server/controllers/distribution-board.controller.ts`, `src/app/projects/[projectId]/page.tsx`.
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
`data/leistungsbilanz.db` und bleibt beim Stoppen erhalten.
### 3) Genau eine Stromkreisliste pro Verteilung
- Anforderung: Jede Verteilung besitzt genau eine Stromkreisliste.
- Status: Erfüllt.
- Umsetzung: Beim Erstellen einer Verteilung wird automatisch eine Stromkreisliste angelegt (`createDistributionBoard` + `CircuitListRepository.createForDistributionBoard`).
```powershell
docker compose ps
docker compose logs --follow
docker compose down
```
### 4) Tabellarische Stromkreisbearbeitung
- Anforderung: Einträge als Tabellenzeilen anlegen, bearbeiten, löschen.
- Status: Erfüllt.
- Umsetzung: `src/app/projects/[projectId]/circuit-lists/page.tsx`, API `src/server/controllers/consumer.controller.ts`.
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
Produktionsdeployment. Details stehen in
[Deployment und Betrieb](docs/deployment.md).
### 5) Bis zu drei parallele Stromkreislisten
- Anforderung: 13 Listen parallel, Standard = 1, inkl. Kopieren zwischen Listen.
- Status: Erfüllt.
- Umsetzung: `activeListCount`, Slot-Logik und Kopierfunktionen in `src/app/projects/[projectId]/circuit-lists/page.tsx`.
## Direkte lokale Entwicklung
### 6) Geräteverwaltung global + projektbezogen
- Anforderung: Globale Geräteliste und Projektgeräteliste.
- Status: Erfüllt.
- Umsetzung: Seiten `src/app/projects/page.tsx` und `src/app/projects/[projectId]/page.tsx`, API-Routen für `global-devices` und `project-devices`.
Voraussetzungen:
### 7) Geräte zwischen global und Projekt kopieren
- Anforderung: Kopieren in beide Richtungen.
- Status: Erfüllt.
- Umsetzung: `copyGlobalDeviceToProject` und `copyProjectDeviceToGlobal`.
- Node.js 22
- npm
### 8) `name` + `displayName` bei Geräten
- Anforderung: Interner Name und Anzeigename getrennt.
- Status: Erfüllt.
- Umsetzung: DB-Schema, Validierung, UI und Copy-Flows sind angepasst.
```powershell
npm ci
npm run db:migrate
npm run db:verify:circuit-schema
npm run dev:api
```
### 9) Geräte-Link an Stromkreiseinträgen
- Anforderung: Eintrag kann mit Projektgerät verknüpft/entkoppelt werden; verknüpfte Einträge aktualisieren sich bei Geräteänderung.
- Status: Erfüllt.
- Umsetzung: Felder `projectDeviceId` + `isLinkedToDevice`, Sync in `syncLinkedConsumersFromProjectDevice`.
In einem zweiten Terminal:
### 10) Unvollständige Einträge zulassen
- Anforderung: Einträge sollen grundsätzlich auch unvollständig möglich sein.
- Status: Teilweise erfüllt.
- Umsetzung: Backend akzeptiert optionale Kernfelder und setzt Defaults.
- Hinweis: Das manuelle Schnellformular im UI verlangt weiterhin einen Namen für den direkten Anlege-Flow.
```powershell
npm run dev:web
```
### 11) Add Count (mehrere Einträge aus einem Gerät erzeugen)
- Anforderung: getrennt von `quantity`.
- Status: Erfüllt.
- Umsetzung: `addCount` bei „Projektgerät übernehmen“ in der Stromkreislistenansicht.
Frontend und API laufen anschließend auf denselben Ports wie im Docker-Setup.
### 12) Duplizieren und Kopieren von Einträgen
- Anforderung: Duplizieren in derselben Liste + Kopieren in andere Listen.
- Status: Erfüllt.
- Umsetzung: Zeilenaktionen „Dupl.“ und Listenkopie, plus Auswahl-Kopie.
## Qualitätssicherung
### 13) Sortieren, Filtern, Bulk-Edit
- Anforderung: erweiterte Tabellenfunktionen.
- Status: Erfüllt (Basisumfang).
- Umsetzung: Filterfeld, Sortierfeld/-richtung und Sammeländerung für Auswahlwerte.
```powershell
npm test
npm run build:api
npm run build:web
npx tsc --noEmit -p tsconfig.next.json
```
### 14) Räume und Etagen
- Anforderung: Projektbezogene Räume/Etagen und Zuordnung zu Einträgen.
- Status: Erfüllt.
- Umsetzung: Floors/Rooms in Projektansicht und Raumzuordnung in Stromkreisliste.
Wichtige Datenbankbefehle:
### 15) Projektspezifische Spannungsstandards (1-ph/3-ph)
- Anforderung: 230 V / 400 V als Standard, in Projekteigenschaften editierbar, in Berechnung verwendet.
- Status: Erfüllt.
- Umsetzung: Projekteinstellungen + Nutzung in Berechnungsservice.
```powershell
npm run db:backup
npm run db:migrate
npm run db:verify:circuit-schema
npm run db:generate
```
### 16) Spaltensteuerung in der Tabelle
- Anforderung: Attribute ein-/ausblenden und Spaltenreihenfolge ändern.
- Status: Erfüllt.
- Umsetzung: Spaltenmanager in `circuit-lists/page.tsx`.
`db:backup` verwendet die SQLite-Online-Backup-API und prüft das Ergebnis auf
Integrität und Fremdschlüsselverletzungen. Vor jeder Migration einer bestehenden
Datenbank ist ein Backup erforderlich.
### 17) Feste Auswahllisten für Domänenfelder
- Anforderung: feste Werte für Felder wie `deviceType`, `phaseType`, Schutz/Kabel usw.
- Status: Erfüllt.
- Umsetzung: zentrale Listen in `src/shared/constants/consumer-option-lists.ts`, Validierung über `z.enum(...)`, UI-Selects.
`db:migrate:legacy-consumers` und `db:backfill:sections` sind ausschließlich
Upgrade-Werkzeuge für ältere Datenbanken. Neue Installationen benötigen sie nicht.
### 18) Tests über reine Formeln hinaus
- Anforderung: zusätzliche Tests für Link-/Eintragsverhalten.
- Status: Erfüllt.
- Umsetzung: `tests/consumer-linking.service.test.ts` und `tests/consumer-schema-options.test.ts`.
## Dokumentation
## Technische Architektur (für neue Entwickler)
- [Dokumentationsübersicht](docs/README.md)
- [Aktuelle Architektur](docs/current-architecture.md)
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
- [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.md)
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
### Backend
- Node.js + Express
- Einstieg: `src/server/index.ts`
- API-Module:
- Projekte/Verteilungen/Räume/Etagen
- Verbraucher (Stromkreiseinträge)
- globale und projektbezogene Geräte
### Frontend
- Next.js (App Router) + React + Bootstrap
- Hauptseiten:
- `src/app/projects/page.tsx` (Projektliste + globale Geräte)
- `src/app/projects/[projectId]/page.tsx` (Projektdetails, Verteilungen, Räume/Etagen, Projektgeräte)
- `src/app/projects/[projectId]/circuit-lists/page.tsx` (Stromkreislisten-Editor)
### Datenbank
- SQLite + Drizzle ORM
- Schema unter `src/db/schema`
- Migrationen unter `src/db/migrations`
### Domänen- und Rechenlogik
- Berechnung: `src/domain/calculations/power-calculation.ts`
- Anreicherung/Businesslogik: `src/domain/services/power-balance.service.ts`
- Geräte-Link-Logik: `src/domain/services/consumer-linking.service.ts`
## Schneller Entwicklungsstart
### Mit Docker (empfohlen fuer kurze Sessions)
Voraussetzung: Docker Desktop mit aktivem Docker-Compose-Plugin.
1. Sicherstellen, dass keine direkt gestartete API und kein direkt gestartetes Frontend mehr auf Port 3000/3001 laufen.
2. `npm run docker:up` (baut und startet beide Container im Hintergrund)
3. Frontend unter `http://localhost:3001` oeffnen.
Beim Start fuehrt der API-Container ausstehende Drizzle-Migrationen und die Circuit-First-Schemapruefung aus. Die vorhandene SQLite-Datenbank wird ueber `./data:/app/data` eingebunden und bleibt nach `npm run docker:down` erhalten. Quellcodeaenderungen unter `src/` werden von beiden Entwicklungsservern automatisch erkannt.
Weitere Docker-Befehle:
- `npm run docker:down` - Frontend und API stoppen
- `npm run docker:logs` - laufende Containerlogs anzeigen (`Ctrl+C` beendet nur die Logansicht)
- `docker compose ps` - Status und Healthchecks anzeigen
- `Invoke-WebRequest http://localhost:3000/health` - API-Healthcheck pruefen
Bewusster Datenbank-Reset unter PowerShell:
1. `npm run docker:down`
2. `npm run db:backup`
3. `Remove-Item -LiteralPath .\data\leistungsbilanz.db`
4. `npm run docker:up`
Der Reset entfernt die aktive lokale Datenbank. Die zuvor erzeugte Sicherung bleibt unter `data/backups/` erhalten.
### Ohne Docker
1. `npm install`
2. `npm run db:migrate`
3. `npm run db:verify:circuit-schema`
4. `npm run dev:api` (API auf Port 3000)
5. In einem zweiten Terminal `npm run dev:web` (Frontend auf Port 3001)
## Wichtige Befehle
- `npm run dev:api` API lokal starten
- `npm run dev:web` Frontend lokal starten
- `npm run build:api` Backend bauen
- `npm run build:web` Frontend bauen
- `npm test` Testlauf
- `npm run db:generate` Migrationen generieren
- `npm run db:migrate` Migrationen ausführen
### Circuit-First lokale Migration
- `npm run db:backup` lokale SQLite sichern
- `npm run db:migrate` pending Migrationen ausführen
- `npm run db:verify:circuit-schema` Circuit-First Tabellenprüfung
- `npm run db:backfill:sections` Default-Sections für bestehende Listen anlegen
- `npm run db:migrate:legacy-consumers` Legacy-Consumers explizit in Circuit-First überführen
Siehe auch: `docs/local-db-circuit-first-migration.md`
## Ergänzende Dokumente
- Anforderungen (Quelle): [docs/electrical-load-balance-requirements-context-dump.md](docs/electrical-load-balance-requirements-context-dump.md)
- Abgleich „Anforderung vs. Implementierung“: [docs/anforderungs-abgleich.md](docs/anforderungs-abgleich.md)
## Circuit-List Editor Dokumentation
- Architektur: [docs/circuit-list-editor-architecture.md](docs/circuit-list-editor-architecture.md)
- Interaktionen: [docs/circuit-list-editor-interactions.md](docs/circuit-list-editor-interactions.md)
- API: [docs/circuit-list-editor-api.md](docs/circuit-list-editor-api.md)
- Migration: [docs/circuit-list-editor-migration.md](docs/circuit-list-editor-migration.md)
- Bekannte Limitierungen: [docs/circuit-list-editor-known-limitations.md](docs/circuit-list-editor-known-limitations.md)
Diese Dokumente beschreiben den aktuellen circuit-first Editorstand und dienen als sichere Entwicklungsbasis fuer Folgeschritte.
Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen
Domänenregeln, unterstützten Einstiegspunkte und Architekturgrenzen.
+50
View File
@@ -0,0 +1,50 @@
# Dokumentationsübersicht
Diese Seite ist der Einstieg in die Projektdokumentation. Aussagen zum aktuellen
System stehen unter „Aktuell“. Zukunftsentwürfe und historische Unterlagen sind
ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden werden.
## Aktuell
- [Aktuelle Systemarchitektur](current-architecture.md) Laufzeit, Modulgrenzen,
Datenfluss und unterstützte Codepfade
- [Stromkreislisten-Architektur](circuit-list-editor-architecture.md)
fachliches Modell und Grid-Aufteilung
- [Editor-Interaktionen](circuit-list-editor-interactions.md) Tastatur,
Drag-and-drop, Sortierung, Filterung und Undo/Redo
- [Editor-API](circuit-list-editor-api.md) unterstützte Circuit-First-Endpunkte
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md)
bewusst noch nicht implementierte oder begrenzte Funktionen
## Entwicklung und Betrieb
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
- [Deployment und Betrieb](deployment.md)
- [Lokale Circuit-First-Datenbankmigration](local-db-circuit-first-migration.md)
- [Retained Legacy-Datenmigration](circuit-list-editor-migration.md)
ausschließlich für Upgrades alter Datenbanken
## Zukunftsarchitektur
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
## Fachliche Referenzspezifikation
Die folgenden Dokumente beschreiben Zielregeln und Anforderungen. Nicht jeder
Punkt ist bereits umgesetzt; der aktuelle Stand ergibt sich aus Architektur,
Limitierungen und Roadmap.
- [Domänenkontext](spec/01-domain-context.md)
- [Funktionale Anforderungen](spec/02-functional-requirements.md)
- [Datenmodellkonzept](spec/03-data-model-concept.md)
- [UI- und Interaktionsanforderungen](spec/04-ui-interaction-requirements.md)
- [Verknüpfte Projektgeräte und Synchronisierung](spec/05-linked-devices-and-sync.md)
## Archiv
- [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) historischer
Arbeitsstand, keine aktuelle Implementierungsanweisung
@@ -1,4 +1,8 @@
# Codex Prompt Templates
# Archived Initial Codex Prompt Templates
> Historical document. These prompts predate the supported Circuit-First
> architecture and must not be used as current implementation instructions. Use
> `AGENTS.md`, `docs/current-architecture.md` and the current roadmap instead.
Use these prompts step by step. Do not ask Codex to build the whole application in one pass.
+240 -108
View File
@@ -2,10 +2,228 @@
## Scope
The circuit-first editor uses tree + circuit + row endpoints. Legacy consumer endpoints still exist for compatibility and migration, but should not be extended for new circuit-list behavior.
The circuit-first editor uses tree, circuit, row and project-device endpoints.
All paths below are mounted below `/api`. There is no Consumer application API.
Project responses from `GET /projects`, `GET /projects/:projectId` and the
circuit-tree endpoint include `currentRevision`. Versioned project commands use
this value for optimistic concurrency checks.
### Project Commands and History
- `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": { ... } }`
- `POST /projects/:projectId/history/undo`
- `POST /projects/:projectId/history/redo`
- 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.reorder-sections`,
`circuit.renumber-section` and
`project-device.update`, `project-device.insert`, `project-device.delete` and
`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, 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
explicitly identified empty target circuit and moves one or multiple existing
rows into it. Its inverse restores every row to its exact prior position and
deletes the generated circuit only if its fields and complete row set still
match the recorded state.
`circuit.reorder-section` requires one assignment for every circuit currently
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
targets occupied by other sections, then applies swaps through collision-safe
temporary identifiers. Undo restores the exact prior identifiers; sort
positions and device rows remain unchanged.
`project-device.sync-rows` represents `synchronize`, `disconnect` and
`reconnect` operations. Every selected row carries complete expected and target
snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`.
All rows, the inverse command, revision and history-stack transition commit or
roll back together. Disconnect/reconnect may only change the link; stale row
snapshots and cross-project devices or rows are rejected.
`project-device.update` changes one or multiple canonical ProjectDevice fields
and derives its inverse from the persisted device. It validates project
ownership and never writes linked `CircuitDeviceRow` values; synchronization
remains a separate explicit command.
`project-device.insert` stores the complete canonical device with a stable id.
User inserts cannot attach existing rows. `project-device.delete` captures the
complete device and every currently linked row before deletion. Its inverse
recreates the same device and reconnects only rows whose complete disconnected
snapshot still matches; device, links, revision and history transition are
atomic.
### Project Device CRUD
- `GET /project-devices/projects/:projectId`
- lists the project's reusable devices
- `POST /project-devices/projects/:projectId`
- `PUT /project-devices/projects/:projectId/:projectDeviceId`
- request: all canonical device fields plus `expectedRevision`
- response: `{ "projectDevice": { ... }, "revision": { ... }, "history": { ... } }`
- `DELETE /project-devices/projects/:projectId/:projectDeviceId`
- request: `{ "expectedRevision": 12 }`
- response: the project command result with the updated history state
- `POST /project-devices/projects/:projectId/import-global/:globalDeviceId`
- request: `{ "expectedRevision": 12 }`
- creates an independent project device with a stable UUID
- response shape matches ProjectDevice create/update
These write endpoints execute the typed `project-device.update`,
`project-device.insert` and `project-device.delete` commands. A stale revision
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, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
- executes `project.update-settings` as one atomic revision
- response:
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
- persistent Undo/Redo restores metadata, voltage values and the enabled
distribution-board supply types together; project-device and circuit
voltages are recalculated from the restored project settings in the same
transaction
- project-device and circuit voltage are derived values and are not accepted
as editable frontend fields
- at least one of `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe` must be enabled;
a supply type currently used by a distribution board cannot be disabled
- unchanged values are rejected without creating a revision; a stale
revision returns `409 PROJECT_REVISION_CONFLICT`
Example:
```json
{
"expectedRevision": 0,
"description": "Rename circuit",
"command": {
"schemaVersion": 1,
"type": "circuit.update",
"payload": {
"circuitId": "cir_1",
"changes": [
{ "field": "displayName", "value": "Sockets East" }
]
}
}
}
```
A stale `expectedRevision` returns HTTP `409` with
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
## Circuit-First Endpoints
### Distribution Board Setup
- `POST /projects/:projectId/distribution-boards`
- body:
`{ "name": "UV-02", "floorId": "floor_1", "supplyType": "SV", "expectedRevision": 12 }`
- `floorId` may be `null`; a non-null floor must belong to the project
- `supplyType` is one of `AV`, `SV`, `EV`, `USV`, `MSR` or `SiBe` and must
be enabled in the project settings
- 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`
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
- body:
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
- executes `distribution-board.update`; floor and supply type are restored
together by persistent Undo/Redo
### Project Floors and Rooms
- `POST /projects/:projectId/floors`
- body: `{ "name": "EG", "expectedRevision": 13 }`
- executes `project-floor.insert` with a stable floor id
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
- `POST /projects/:projectId/rooms`
- body:
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
- executes `project-room.insert` with a stable room id; `floorId` is optional
and must belong to the project when present
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
- Persistent Undo removes only unchanged, unreferenced records. A floor with
assigned rooms or distribution boards and a room referenced by device rows
or retained upgrade data are rejected instead of silently clearing foreign
keys.
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
### Tree Endpoint
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
@@ -16,6 +234,7 @@ Response sketch:
```json
{
"circuitListId": "cl_1",
"currentRevision": 12,
"sections": [
{
"id": "sec_1",
@@ -40,115 +259,24 @@ Response sketch:
}
```
### Circuit CRUD
### Circuit Structure
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits`
- create circuit in list/section
- `PATCH /circuits/:circuitId`
- update circuit-level fields (BMK, protection, cable, reserve flag, etc.)
- `DELETE /circuits/:circuitId`
- delete circuit (and related rows via DB relations)
- `GET /circuit-sections/:sectionId/next-identifier`
- preview next identifier for section (`prefix + maxSuffix + 1`)
Request sketch (`POST .../circuits`):
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, move, reorder, renumber, identifier-restore, Circuit DELETE or
CircuitDeviceRow DELETE routes.
```json
{
"sectionId": "sec_1",
"equipmentIdentifier": "-2F14",
"displayName": "Sockets East",
"sortOrder": 140
}
```
## Removed Legacy Endpoints
### Device Row CRUD
- `POST /circuits/:circuitId/device-rows`
- create row inside a circuit
- `PATCH /circuit-device-rows/:rowId`
- update row values
- `DELETE /circuit-device-rows/:rowId`
- delete row
### 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" }
]
}
```
## Legacy Endpoints (Temporary)
- `GET /consumers/projects/:projectId`
- `POST /consumers`
- `PUT /consumers/:consumerId`
- `DELETE /consumers/:consumerId`
These remain for migration/legacy views. New circuit-list interactions must use circuit-first endpoints above.
The former `/consumers` read/write endpoints were removed after every retained
consumer had a verified Circuit-First migration mapping. Database upgrade tooling
reads retained legacy rows directly; application features must use the Circuit-First
endpoints above.
## Linked Project Device Review
@@ -156,12 +284,16 @@ These remain for migration/legacy views. New circuit-list interactions must use
- returns all linked circuit device rows with distribution-board/circuit context and field differences
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
- applies only explicitly selected fields to explicitly selected linked rows
- `POST /project-devices/projects/:projectId/:projectDeviceId/restore`
- restores the captured pre-sync values for session-local undo
- request: selected `rowIds`, selected `fields` and `expectedRevision`
- response: updated preview, revision and project history state
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
- disconnects explicitly selected rows without changing their local values
- `POST /project-devices/projects/:projectId/:projectDeviceId/reconnect`
- restores a disconnected link if the row has not been linked elsewhere meanwhile
- request: selected `rowIds` and `expectedRevision`
- response: updated preview, revision and project history state
Both writes execute `project-device.sync-rows`. Undo uses the project-wide
`POST /projects/:projectId/history/undo` endpoint; reconnect is the validated
inverse command. The former direct restore/reconnect endpoints are removed.
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
project device never triggers synchronization implicitly.
+18 -4
View File
@@ -37,12 +37,16 @@ The pure grid modules have no React state and are covered by focused unit tests.
- `cosPhi`
- room snapshots
- category/cost group and related row attributes
- canonical `phaseType` values `single_phase` or `three_phase`; the
frontend always presents these as `1-phasig` or `3-phasig` and edits
them through a selection control
- `ProjectDevice`
- Reusable device template entity at project level.
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
- Legacy `Consumer`
- Old row-first model still present for compatibility/migration.
- Should not be extended for new circuit-list behavior.
- Retained legacy migration source
- Old `consumers` rows, mappings and reports are available only to explicit
database upgrade tooling.
- They are not application-domain entities and have no UI or API.
## Why A Circuit Is Not One Row
@@ -104,4 +108,14 @@ Primary circuit-first editor route:
- `/projects/:projectId/circuit-lists/:circuitListId/tree-edit`
Legacy route remains separate (read-only/preview/migration transition path) and is intentionally not merged into the editable tree editor.
The sibling `/tree` route is a read-only structure preview. Old
`/projects/:projectId/circuit-lists` bookmarks redirect to the project page.
## Future Persistence Direction
Persistent undo/redo, project revisions, logical snapshots, external-model exchange and PostgreSQL readiness are specified in [Project History and External Model Architecture](./project-history-and-external-model-architecture.md).
Critical multi-write commands already use explicit persistence transaction
adapters. The next step is to compose these command boundaries into server-side,
project-scoped revisions and change sets. Database backups remain separate from
user-visible project snapshots.
+25 -5
View File
@@ -11,6 +11,11 @@ Inline cells are static text by default. A cell enters edit mode by:
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
Committed cells on existing circuits and device rows are persisted as typed
project commands with an optimistic project revision. Only the edited fields
are included, so an older tree response cannot write unrelated fields back.
The server records the inverse in the same transaction.
## `selectedCell` vs `editingCell`
- `selectedCell` tracks spreadsheet navigation focus.
@@ -44,6 +49,8 @@ For insertion, a circuit-level cell creates a reserve circuit directly after the
For deletion, a circuit-level cell targets the complete circuit and a device-level cell targets only the device row. Deleting the last device requires an explicit choice between keeping the empty circuit as reserve, deleting the complete circuit, or cancelling. Delete commands are undoable and never renumber remaining circuits.
Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction.
## Equipment Identifier Validation
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
@@ -54,6 +61,7 @@ Each section has a trailing placeholder row showing `-frei-` in BMK column:
- serves as drop target for creating new circuits from project devices or moved rows
- editable placeholder cells can create a new circuit + first row through the same edit command flow
- a new circuit and its initial row(s) are created in one SQLite transaction
## Add Circuit Behavior
@@ -66,6 +74,7 @@ Intent is separated by drag source type:
- project device drag:
- drop to section/placeholder -> create new circuit with linked row
- the new circuit and linked row are committed atomically
- drop to existing circuit row -> append row to that circuit
- lighting devices are accepted only by the lighting section
- other devices are accepted only by the section matching their phase type
@@ -79,6 +88,7 @@ Intent is separated by drag source type:
- circuit drag (BMK handle):
- reorder circuits inside same section only
- cross-section reorder is rejected
- the complete section order is validated and stored in one SQLite transaction
- bulk device row move:
- supported via multi-selection + drag
- multi-circuit move:
@@ -101,6 +111,7 @@ Cross-section device moves show confirmation-required feedback before drop. The
- disabled while filters are active
- undoable via command history
- each complete section order is stored atomically
- until applied, sort remains view-only
## Column Configuration
@@ -111,7 +122,16 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Undo/Redo
Undo/redo wraps editor operations as command objects with async `redo`/`undo` and reloads tree after each command.
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:
@@ -122,7 +142,7 @@ Covered operations include:
- renumber and identifier update flows
- apply sorted order
Current limitations:
- session-local only
- no persisted history across browser reload
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.
+14 -4
View File
@@ -1,10 +1,20 @@
# Circuit List Editor Known Limitations
- Undo/redo history is session-local only.
- Undo/redo history is not persisted across reloads or between users.
- No global cross-project device library workflow is 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
versioning, permissions or controlled synchronization model.
- Final electrical sizing logic is not implemented yet.
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
- Sorting is view-only until users explicitly apply sorted order.
- Cross-section circuit drag-reorder is intentionally blocked.
- Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline.
- Legacy consumer UI and server endpoints are removed; retained source rows and migration mappings remain available only for upgrade traceability.
+17 -2
View File
@@ -1,5 +1,10 @@
# Circuit List Editor Migration
## Status
Upgrade-only. New installations already use the Circuit-First schema and do not
run this workflow.
## Goal
Migrate legacy row-first consumers into the circuit-first model without deleting legacy data.
@@ -13,6 +18,8 @@ Legacy `Consumer` rows map into:
Multiple legacy consumers can map into one circuit when they share normalized circuit identity.
For one circuit list, all new circuits, device rows, trace mappings and the migration report are committed in one SQLite transaction. A failed run therefore leaves none of those prepared migration writes behind.
## Grouping Strategy (`circuitNumber`)
Migration groups legacy rows by normalized `circuitNumber`:
@@ -41,6 +48,12 @@ Run in this order for local database workflows:
5. Migrate legacy consumers:
- `npm run db:migrate:legacy-consumers`
The migration command finishes with a cutover verification across the complete
database. It exits with an error while any legacy consumer lacks a migration
mapping, including consumers that cannot be migrated because they have no circuit
list assignment. The legacy multi-list UI was removed only after this check passed;
old `/projects/:projectId/circuit-lists` bookmarks redirect to the project page.
## Validation Checks
After migration, verify:
@@ -70,6 +83,8 @@ Actions:
Do not delete legacy consumers yet.
- legacy endpoints/views may still depend on them
- legacy read/write endpoints have been removed from the application server
- migration trace tables reference old/new mapping
- keeping legacy rows allows comparison and rollback validation during transition
- retained rows allow upgrade verification and audit of the completed mapping
- a future automatic cleanup migration must migrate and verify old databases
before dropping these source and trace tables
+351
View File
@@ -0,0 +1,351 @@
# Aktuelle Systemarchitektur
## Status und unterstützter Pfad
Der unterstützte Editor ist Circuit-First:
`Project → DistributionBoard → CircuitList → CircuitSection → Circuit → CircuitDeviceRow`
Ein Stromkreis ist nicht dasselbe wie eine Gerätezeile. BMK, Schutz- und Kabeldaten
gehören zum Stromkreis; Last-, Raum- und Kategoriedaten gehören zur Gerätezeile.
Die frühere Consumer-Oberfläche und ihre API sind entfernt. Die Tabelle
`consumers` sowie Mappings und Reports bleiben ausschließlich erhalten, damit
ältere Datenbanken über den expliziten Upgrade-Befehl migriert und geprüft werden
können.
## Laufzeit
```text
Browser :3001
Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
Repository / Transaktion
data/leistungsbilanz.db (SQLite)
```
Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das
Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank
liegt über einen Host-Mount außerhalb des Containers.
## Wichtige Einstiegspunkte
- `src/app/projects/page.tsx` Projektliste und globale Gerätebibliothek
- `src/app/projects/[projectId]/page.tsx` Projektstammdaten, Verteilungen,
Räume und Projektgeräte
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
unterstützte Editorroute
- `src/frontend/components/circuit-tree-editor.tsx` Editorzustand,
Befehlsausführung, Drag-and-drop und der schrittweise Historien-Cutover
- `src/frontend/components/circuit-grid-*.ts` reine Grid-Projektion,
Zellbesitz, Einfügen und Sicherheitsregeln
- `src/frontend/utils/api.ts` typisierte Frontend-API-Aufrufe
- `src/server/index.ts` und `src/server/routes/` API-Komposition
- `src/domain/services/` fachliche Command- und Synchronisierungsregeln
- `src/server/composition/` Verdrahtung fachlicher Services mit konkreten
SQLite-Repositories
- `src/db/repositories/` Abfragen, Persistenzmapper und Transaktionsadapter
- `src/db/schema/` und `src/db/migrations/` SQLite-Schema und Migrationen
## Daten- und Befehlsfluss
1. Das Grid projiziert den geladenen Circuit-Tree in sichtbare Zeilen.
2. Eine Benutzeraktion wird im Frontend validiert und als API-Befehl gesendet.
3. Controller validieren Requestdaten mit Zod.
4. Domain-Services prüfen fachliche Regeln wie BMK-Eindeutigkeit,
Abschnittszuordnung und Reserveverhalten.
5. Repositories schreiben Daten. Kritische Mehrfachschreibvorgänge besitzen
explizite SQLite-Transaktionsadapter mit Commit-/Rollback-Integrationstests.
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
Viewport soweit möglich wieder her.
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. Eine direkt getestete Persistence-Funktion schreibt diese
Historienmetadaten innerhalb der zentralen Command-Transaktion optimistisch und
atomar fort. Vorwärts- und Rückwärtskommandos besitzen einen
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten
Circuit-, Gerätezeilen-, Projektgeräte-, Projektstruktur- 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. 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.
Alle unterstützten Runtime-Project-Command-Stores einschließlich der
vollständigen Snapshot-Wiederherstellung verwenden dabei
`project-command-transaction.persistence.ts` als gemeinsame äußere
Transaktionsgrenze. Sie führt Fachänderung, Revisions-Append und
History-Transition in fester Reihenfolge innerhalb derselben SQLite-Transaktion
aus. Für CircuitDeviceRow-Feldänderungen übernimmt eine typisierte Variante den
während der Fachänderung um Override-Metadaten ergänzten Forward-Command. Die
fachlichen Validierungs-, Forward- und Inversenregeln bleiben im jeweiligen
Store.
Die Low-Level-Funktion `appendProjectRevision` bleibt ein internes Detail dieser
Persistenzgrenze und wird direkt mit einer realen SQLite-Transaktion getestet.
Ein eigenständiges Runtime-Revisions-Repository existiert nicht.
`ProjectDeviceSyncService` und `CircuitNumberingService` kennen nur schmale,
fachlich benannte Reader-Interfaces. Ihre SQLite-Repositories werden
ausschließlich in `src/server/composition/` erzeugt und injiziert.
Auch die allgemeinen Projekt-, Geräte-, Raum-, Geschoss- und Circuit-
Repositories verlangen einen expliziten `AppDatabase`-Kontext. Ihre
Anwendungsinstanzen werden zentral in
`src/server/composition/application-repositories.ts` erzeugt; Controller
importieren weder den globalen SQLite-Client noch konkrete Repository-Klassen.
`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
sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über die
ö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-, 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, jeden aufgeführten benannten oder automatischen Stand nach
expliziter Bestätigung wiederherstellen und die paginierte Revisions-Timeline
mit deutschen Quellen- und Änderungsbezeichnungen lesen. Die Snapshot-Liste
ordnet jede positive `sourceRevision` serverseitig ihren unveränderlichen
Revisionsmetadaten zu. Dadurch zeigt auch ein älterer automatischer Stand die
auslösende Änderung, ohne eine zweite frei formulierte Beschreibung zu
speichern oder von den zuletzt paginiert geladenen Timeline-Einträgen
abzuhängen. Revision null wird als Projektstart dargestellt.
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;
Reservewerte aller beteiligten Stromkreise werden atomar neu abgeleitet. Das
Kompositkommando `circuit-device-row.move-with-new-circuit` bildet auch das
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.
Die Projektgerätepalette belegt keine permanente Layoutspalte mehr. Sie wird
über die Editor-Toolbar als überlagernder Drawer geöffnet, während das
Stromkreis-Grid standardmäßig die gesamte verfügbare Breite nutzt. Auswahl,
Schnelleinfügen und die vorhandenen Drag-and-drop-Payloads bleiben im Drawer
unverändert verfügbar; während eines aktiven Projektgeräte-Drags kann er nicht
geschlossen werden. Der Drawer liegt am rechten Fensterrand und verdeckt damit
nicht die führenden BMK- und Anzeigenamenspalten. Das Grid zeigt Geräte- und
Stromkreisleistungen in einer gemeinsamen Spalte `Gesamtsumme`: Gerätezeilen
verwenden `rowTotalPower`, Stromkreis-Sammelzeilen `circuitTotalPower`.
Zahlen werden ausschließlich für die Anzeige deutsch und begrenzt formatiert;
gespeicherte Werte und Bearbeitungsentwürfe behalten ihre volle Genauigkeit.
Jeder Abschnitt liefert und zeigt seine aufsummierte Stromkreisleistung. Die
Stromkreisliste zeigt außerdem die ungefilterte Gesamtleistung des Verteilers,
den am Verteiler gespeicherten Gleichzeitigkeitsfaktor und die daraus
abgeleitete Gesamtleistung unter Berücksichtigung dieses Faktors. Sortierung
und Filter verändern diese fachlichen Summen nicht.
Reihenfolge und Sichtbarkeit der Grid-Spalten sind reine UI-Präferenzen. Sie
werden im Browser unter einem projektspezifischen Schlüssel gespeichert und
deshalb beim Wechsel zwischen Verteilern desselben Projekts wiederverwendet,
ohne Projektrevisionen oder fachliche Snapshots zu erzeugen.
`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
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,
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. 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
gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel
atomar. Verknüpfte Stromkreiszeilen werden dabei bewusst nicht automatisch
synchronisiert. Der ProjectDevice-`PUT`-Endpunkt und die Projektseite verwenden
diesen Command mit optimistischer Revisionsprüfung.
`project-device.insert` und `project-device.delete` versionieren außerdem den
Lebenszyklus eines Projektgeräts mit stabiler UUID. Beim Löschen speichert das
inverse Insert den vollständigen Gerätestand sowie vollständige, nach dem
Löschen erwartete Snapshots aller zuvor verknüpften Gerätezeilen. Undo setzt
die Links nur zurück, wenn diese Zeilen weiterhin zum Projekt gehören,
unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revision
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 Projektname, interne und externe
Projektnummer, Bauherr, Beschreibung, beide Standardspannungen sowie die im
Projekt freigeschalteten Verteiler-Netzarten als eine atomare Änderung. Der
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
eine Netzart muss aktiv bleiben und eine bereits von einer Verteilung
verwendete Netzart kann nicht deaktiviert werden. Die Projektseite bearbeitet diese Angaben in einem
beschrifteten Einstellungsmodal statt in einer permanenten Formularkarte. 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.
Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und
ausführbar; fehlende Metadaten werden dabei als `null` behandelt.
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
damit Änderungen weiterhin niemals still synchronisiert werden.
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
`POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
projektgebundenen Endpunkt beschränkt.
`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. Verteilungen besitzen eine optionale
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
Freigabe der Netzart in den Projekteinstellungen.
`distribution-board.update` versioniert Etage, Netzart und den
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
Snapshot-Schema 6 und der portable Projekttransfer enthalten diesen Wert;
Schema 5 und älter werden mit dem neutralen Faktor `1` hochgestuft.
Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie
gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
Projektauswahl hochgestuft.
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
Raum wird nur entfernt,
solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf
ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den
aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg
mehr.
## Projektgeräte
`ProjectDevice` verwendet ausschließlich die kanonischen Circuit-First-Felder:
`phaseType`, `powerPerUnit`, `simultaneityFactor`, `cosPhi`, `remark` sowie
optionale technische und kategorisierende Felder.
Beim Einfügen entsteht eine verknüpfte `CircuitDeviceRow`. Der Anzeigename wird
kopiert, aber nicht still synchronisiert. Spätere Änderungen am Projektgerät
werden als Diff angezeigt und nur für ausdrücklich gewählte Felder und Zeilen
übernommen.
Die globale Gerätebibliothek ist ein einfacher, datenbankweiter Vorlagenbestand.
Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
## Persistenz und Migration
- SQLite ist die aktuell unterstützte Datenbank.
- Fremdschlüssel werden für jeden Datenbankkontext aktiviert.
- `npm run db:migrate` wendet Drizzle-Migrationen an.
- `npm run db:verify:circuit-schema` prüft erforderliche und entfernte Spalten.
- `npm run db:backup` erzeugt ein konsistentes und verifiziertes Online-Backup.
- `npm run typecheck:scripts` prüft alle TypeScript-Wartungs- und
Upgrade-Skripte mit ihren Anwendungspfaden, ohne Code zu erzeugen.
- Angewendete Migrationen werden niemals nachträglich verändert.
- `db:migrate:legacy-consumers` ist Upgrade-Werkzeug, kein Anwendungspfad.
Der fachliche Migrationsdienst kennt nur schmale Reader-/Store-Ports unter
`src/domain/ports`; konkrete SQLite-Repositories werden ausschließlich im
CLI-Skript zusammengesetzt.
- Allgemeine Circuit-, Gerätezeilen-, CircuitList- und DistributionBoard-
Repositories stellen im Anwendungspfad nur noch benötigte Leseabfragen bereit.
Fachliche Schreibvorgänge liegen in den typisierten Command-Repositories;
Upgrade-Schreibvorgänge bleiben in expliziten Migrationsadaptern.
- Die vollständige Verteilungs-Testfixture liegt unter
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
Produktions-Schreibweg.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Noch nicht unterstützt
- Mehrbenutzerbetrieb und Konfliktauflösung
- Revit-/CSV-/IFCGUID-Round-trip
- vollständige elektrische Dimensionierung
- Produktionsdeployment
Details: [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) und
[Zukunftsarchitektur](project-history-and-external-model-architecture.md).
+60
View File
@@ -0,0 +1,60 @@
# Deployment und Betrieb
## Aktueller Status
Es gibt derzeit kein unterstütztes Produktionsdeployment.
`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
öffentlich erreichbar gemacht werden.
## Entwicklungs-Topologie
| Komponente | Port | Healthcheck | Persistenz |
| --- | ---: | --- | --- |
| Next.js Web | 3001 | `GET /` | keine |
| Express API | 3000 | `GET /health` | `./data:/app/data` |
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
Verwendete Umgebungsvariablen:
- `PORT` API-Port, Standard `3000`
- `API_INTERNAL_URL` internes API-Ziel des Next.js-Rewrites, im Compose-Netz
`http://api:3000`
- `NEXT_TELEMETRY_DISABLED=1`
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale
Dateibeobachtung in Docker
Beim API-Start laufen zuerst `npm run db:migrate` und
`npm run db:verify:circuit-schema`.
## Voraussetzungen für ein späteres Produktionssetup
Vor einer produktiven Installation werden mindestens benötigt:
- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript
- TLS-Termination und Reverse Proxy
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
- definierte Secrets- und Konfigurationsverwaltung
- persistenter, gesicherter Datenbankbetrieb
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
- Monitoring, strukturierte Logs und Alarmierung
- getestete Backup-, Restore- und Rollback-Prozeduren
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
Mehrbenutzerbetrieb erforderlich ist
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
lokalen Entwicklungsstart in der README.
## Backup und Wiederherstellung
`npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup,
öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese
Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.
Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei
gestoppter API, gegen eine separate Datei und nach erneuter Integritätsprüfung
erfolgen.
+114
View File
@@ -0,0 +1,114 @@
# Entwicklungs- und Contributor-Workflow
## Erstes lokales Setup
Der empfohlene Einstieg ist Docker Compose:
```powershell
docker compose up --build --detach
docker compose ps
```
Ein sauberer Clone enthält keine SQLite-Datenbank. Beim ersten Start werden
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
angelegt. Das Frontend ist unter <http://localhost:3001> erreichbar.
Für direkte Node.js-Entwicklung:
```powershell
npm ci
npm run db:migrate
npm run db:verify:circuit-schema
```
Danach `npm run dev:api` und `npm run dev:web` in getrennten Terminals starten.
## Leere lokale Datenbank
Eine bestehende lokale Datenbank lässt sich recoverable ersetzen:
```powershell
docker compose down
npm run db:backup
Move-Item -LiteralPath .\data\leistungsbilanz.db .\data\leistungsbilanz.previous.db
docker compose up --build --detach
```
Die verschobene Datei und das verifizierte Backup bleiben lokal erhalten. Vor dem
Verschieben müssen die Container beendet sein, damit keine WAL-Sidecar-Datei aktiv
ist.
## Sichere Beispieldaten
Es gibt aktuell keinen versionierten Seed-Datensatz. Dadurch gelangen keine
Projekt- oder Kundendaten versehentlich ins Repository. Für einen lokalen
Testbestand:
1. In der UI ein Projekt `Demo` anlegen.
2. Im Projekt eine Verteilung `UV-01` erstellen.
3. Ein Projektgerät mit unkritischen Fantasiewerten anlegen.
4. Die erzeugte Stromkreisliste öffnen und das Gerät in einen passenden Bereich
ziehen.
Dateien unter `data/` und `data/backups/` dürfen nicht committed werden.
## Arbeitsablauf für Änderungen
1. Von einem aktuellen Branch einen kleinen Feature- oder Fix-Branch erstellen.
2. Relevante Domänenregeln in `AGENTS.md` und die aktuelle Architektur lesen.
3. Änderungen in einem fachlich geschlossenen Paket umsetzen.
4. Tests und Dokumentation proportional zur Änderung aktualisieren.
5. Alle erforderlichen Prüfungen ausführen.
6. Mit einer kurzen, prägnanten Nachricht committen.
Empfohlene Commitnachrichten sind imperativ und beschreiben das Ergebnis, zum
Beispiel `Preserve circuit blocks during filtering`.
## Pflichtprüfungen
```powershell
npm test
npm run build:api
npm run build:web
npm run typecheck:scripts
npx tsc --noEmit -p tsconfig.next.json
git diff --check
```
Bei Docker- oder Laufzeitänderungen zusätzlich:
```powershell
docker compose up --build --detach
docker compose ps
Invoke-WebRequest http://localhost:3000/health
```
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
Testaufbau, der vollständige Projektstrukturen direkt einfügt, gehört nach
`tests/support`. Produktions-Repositories dürfen nicht nur für Testfixtures
erneut direkte Schreibmethoden erhalten.
## Migrationen
1. Bestehende lokale Datenbank sichern: `npm run db:backup`.
2. Schema unter `src/db/schema/` ändern.
3. `npm run db:generate` ausführen.
4. Das generierte SQL vollständig prüfen; es darf nur die beabsichtigte Änderung
enthalten.
5. Upgrade-/Erhaltungstest ergänzen, wenn Spalten oder Beziehungen geändert
werden.
6. `npm test`, `npm run db:migrate` und
`npm run db:verify:circuit-schema` ausführen.
Bereits angewendete SQL-Migrationen dürfen nicht geändert oder neu sortiert
werden. Korrekturen erfolgen immer über eine neue Migration.
## Pull-Request-Handoff
Die Beschreibung sollte enthalten:
- fachliches Ergebnis
- Datenmodell- und API-Auswirkung
- ausgeführte Tests
- erforderliche manuelle Migration oder GUI-Prüfung
- bekannte Einschränkungen oder bewusst verschobene Arbeit
+17 -25
View File
@@ -2,6 +2,10 @@
This project uses SQLite at `data/leistungsbilanz.db` and Drizzle migrations in `src/db/migrations`.
This document is an upgrade runbook for databases created before the Circuit-First
cutover. A clean installation only needs the normal `db:migrate` and schema
verification steps documented in the main README.
## Safe command order
1. Backup local DB (required before schema/data migration)
@@ -10,8 +14,15 @@ This project uses SQLite at `data/leistungsbilanz.db` and Drizzle migrations in
npm run db:backup
```
2. Apply pending schema migrations (includes `0008_circuit_first_model` and the additive
`0009_project_device_circuit_fields` transition)
The command uses SQLite's online backup API, so committed WAL changes are included
even while the application is running. It opens the resulting standalone database
and requires both `PRAGMA integrity_check` and `PRAGMA foreign_key_check` to pass.
Database backups are operational recovery files and remain separate from the future
user-visible project version history.
2. Apply pending schema migrations (includes `0008_circuit_first_model`, the additive
`0009_project_device_circuit_fields` transition and the post-cutover
`0011_project_device_canonical_fields` cleanup)
```bash
npm run db:migrate
@@ -119,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.
@@ -0,0 +1,296 @@
# Project History and External Model Architecture
## Status
This document records architectural direction for future work. It is not a final database schema and does not authorize implementing Revit exchange or migrating to PostgreSQL during unrelated phases.
## Goals
- persist undo/redo across page reloads and application restarts
- provide a project-wide version history with named restore points
- preserve an auditable sequence of user, import, migration and restore operations
- support future Revit/CSV round-trips using stable IFCGUID-based links
- keep the persistence boundary replaceable so PostgreSQL can be adopted later
## Separate Concerns
The following mechanisms solve different problems and must not be conflated:
- database backups protect against database loss or corruption
- project snapshots provide user-visible restore points
- change history records logical application operations
- undo/redo navigates or inverses eligible logical operations
- external import batches describe data exchanged with another model
Database backups remain necessary even after project version history exists.
## Revision and Change-Set Direction
History is project-wide. Every accepted mutating command should eventually execute through one server-side transaction that:
1. validates the expected current project revision
2. applies all domain writes
3. records one immutable change set with sufficient before/after data
4. increments the project revision
The conceptual entities are:
- `ProjectRevision`
- monotonically increasing revision number within one project
- timestamp, actor and source (`user`, `undo`, `redo`, `import`, `restore`, `migration`)
- optional label or description
- `ProjectChangeSet`
- one logical user or system command
- contains one or more domain operations committed atomically
- stores enough before/after state to validate and apply an inverse
- `ProjectSnapshot`
- a logical, database-independent representation of one project revision
- may be created periodically or explicitly named by a user
- accelerates restore without replaying the entire history
Full event sourcing is not required. Current normalized tables remain the primary read model. Immutable change sets plus periodic snapshots provide the required history without forcing every query to rebuild state from events.
## Undo, Redo and Restore Semantics
- Undo applies an inverse as a new recorded revision; it does not delete history.
- Redo reapplies an eligible undone operation as a new recorded revision.
- A new edit after undo may hide the prior redo branch in the normal UI, while the historical revisions remain auditable.
- Restoring a snapshot creates a new revision whose state matches the selected historical revision.
- The server, not React component state, is the source of truth for available undo/redo operations.
- 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 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
Project history must use logical project data, not copies of the SQLite database file. This keeps snapshots:
- project-scoped
- portable across SQLite and PostgreSQL
- independent from unrelated projects
- suitable for validation, comparison and selective restore tooling
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.
Conceptual entities:
- `ExternalModelSource`
- identifies a Revit/IFC model or another external source within a project
- `ExternalImportBatch`
- file name, checksum, import time, format/version and processing status
- `ExternalModelObject`
- stable IFCGUID and optional Revit element identifiers
- source attributes and last-seen import batch
- `ExternalObjectLink`
- explicit relationship to a project device, circuit device row or another future domain entity
- `ExternalParameterMapping`
- maps imported/exported columns to application fields
- `ExternalExportBatch`
- records which project revision and import source produced an exported file
Required behavior:
- IFCGUID uniqueness is scoped to project and external model source.
- Parsing writes to staging first.
- Users preview additions, changes, missing objects and conflicts before applying them.
- Applying an import creates one project revision and one auditable change set.
- Imported source values and locally assigned planning values remain distinguishable.
- Export retains IFCGUID and source identifiers so Revit parameters can be matched deterministically.
- Missing objects and duplicate/conflicting identifiers are reported, never silently reassigned.
## SQLite and PostgreSQL Direction
SQLite remains appropriate for the current local, single-process development workflow and can handle initial external-object volumes. Data volume alone is not a reason to migrate immediately.
PostgreSQL becomes appropriate when one or more of these conditions apply:
- multiple users edit projects concurrently
- the API runs as a shared network service
- background import/export jobs need independent workers
- revision/history queries or external-object datasets need server-side concurrency and operational tooling
- centralized backup, access control and monitoring become required
To preserve that option:
- domain services must not depend directly on `better-sqlite3`
- new multi-write workflows use an injectable transaction/unit-of-work boundary
- database-generated behavior is kept behind repositories
- IDs remain stable UUIDs and revisions use explicit project-scoped sequence numbers
- history snapshots remain logical and database-independent
- SQLite-specific synchronous transaction callbacks are treated as an adapter detail
- real persistence tests are separated so PostgreSQL parity tests can be added later
Drizzle schemas and migrations are dialect-specific and will require a deliberate PostgreSQL schema/migration path. The goal is portable domain behavior, not pretending the current database schema files can be switched without work.
## Near-Term Refactoring Constraints
Completed foundation:
- real SQLite commit/rollback tests cover critical multi-write commands
- transaction ownership for row moves, synchronization, renumbering and migration
is isolated in injected persistence adapters
- database backup and independent restore verification are automated
- the Consumer application path is removed and stable domain IDs are preserved
- editor grid projection and safety rules are separated from React rendering
- projects carry a monotonic `currentRevision` counter starting at zero
- project revision metadata and versioned forward/inverse change-set payloads
have separate persistence entities
- forward and inverse payloads store complete serialized command envelopes;
command type and schema version are derived from those envelopes rather than
duplicated caller metadata
- command serialization rejects non-finite numbers, `undefined`, non-plain
objects and cyclic payloads before a revision transaction starts
- the SQLite revision repository advances the project counter and stores both
history records atomically
- revision appends use an expected-revision compare-and-set and reject stale
callers without writing partial history
- a first internal `circuit.update` command validates project/list/section
ownership and equipment-identifier uniqueness inside its transaction
- circuit updates derive their inverse from the persisted pre-command row and
commit the domain update, inverse command, change set and revision together
- an internal `circuit-device-row.update` command applies the same boundary to
device fields, validates project-device and room ownership and records
automatically derived local override metadata in its normalized forward
command
- integration tests apply the generated inverse as a new `undo` revision and
verify rollback for stale revisions and late history failures on both update
command types
- project-scoped relational undo/redo stacks persist eligible original change
sets independently from immutable audit revisions
- stack transitions enforce LIFO order, keep undo/redo revisions out of the
eligibility stacks and clear the redo branch on a new user command
- domain writes, audit revisions and stack transitions share one transaction;
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
- public command, undo and redo endpoints require an expected revision; undo
and redo reconstruct the eligible persisted inverse or forward command
- the circuit tree returns the project's current revision; existing Circuit
and CircuitDeviceRow cell edits use the public command endpoint, while their
toolbar undo/redo actions use project-wide persistent history
- obsolete direct Circuit and CircuitDeviceRow field-update PATCH routes are
removed so these editor writes cannot bypass revision history
- `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable
row ids, complete deleted-row snapshots and circuit reserve state through
atomic insert/delete, revision and stack transitions
- `circuit.insert` and `circuit.delete` persist a complete circuit block with
zero, one or multiple device rows; undo restores all ids and fields without
renumbering
- standalone Circuit/CircuitDeviceRow insert/delete UI paths use these commands
with client-generated stable UUIDs; obsolete direct structure POST and
CircuitDeviceRow DELETE routes are removed
- `circuit-device-row.move` stores exact expected and target circuit/sort
positions for one or multiple rows, recalculates all affected reserve states
and reconstructs a deterministic inverse across multiple source circuits
- `circuit-device-row.move-with-new-circuit` additionally records the complete
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
- `project-device.update` persists canonical source-device field changes
independently and never synchronizes linked rows implicitly
- `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; the same
transaction derives every project-device and circuit voltage again, so
Undo/Redo cannot leave mixed voltage states
- `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
- `project-floor.insert` and `project-room.insert` persist complete location
records with stable ids; their inverses reject changed or referenced records
so Undo never clears room assignments or device-row links implicitly
- the floor and room POST routes require the current expected revision and the
project page tracks the returned history state
Remaining constraints before completing project version history:
- all current normal project-scoped runtime writes are behind the revision
boundary; extend it whenever further project mutations are introduced
- project creation remains initialization at revision zero, while global device
templates remain outside any single project history
- do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests
## Deferred Decisions
- 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
- supported Revit CSV dialects and parameter mapping UI
- PostgreSQL deployment topology and migration date
+269
View File
@@ -0,0 +1,269 @@
# Kontext für die Revit-Anforderungsplanung
## Zweck dieser Datei
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen
für einen zukünftigen Revit-Datenaustausch zu klären.
Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen.
## Produkt und fachliches Modell
Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
Ihr Kern ist ein tabellenähnlicher Stromkreislisten-Editor für Verteilungen.
Das unterstützte Laufzeitmodell ist:
```text
Project
├── Floor
│ └── Room
├── ProjectDevice
└── DistributionBoard
└── CircuitList
└── CircuitSection
└── Circuit
└── CircuitDeviceRow
```
Wichtige fachliche Grenzen:
- Ein `Circuit` ist nicht dasselbe wie eine Gerätezeile.
- Ein Stromkreis kann null, eine oder mehrere `CircuitDeviceRow` enthalten.
- BMK (`equipmentIdentifier`), Schutz- und Kabeldaten gehören zum Stromkreis.
- Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe
und Kategorie gehören zur Gerätezeile.
- Ein `ProjectDevice` ist eine wiederverwendbare, projektbezogene Vorlage.
- Eine Gerätezeile kann mit einem Projektgerät verknüpft sein, behält aber
lokale Werte und ausdrücklich erfasste Abweichungen.
- Änderungen eines Projektgeräts überschreiben verknüpfte Zeilen niemals
automatisch. Eine Synchronisierung benötigt Vorschau und Benutzerauswahl.
- Manuelle, nicht mit einem Projektgerät verknüpfte Gerätezeilen sind regulär
erlaubt.
- Bestehende BMKs und stabile UUIDs dürfen nicht automatisch verändert werden.
- Neunummerierung ist immer eine ausdrückliche Benutzeraktion.
- `IFCGUID` darf nicht als BMK oder als Ersatz für eine interne UUID verwendet
werden.
Für einen späteren Revit-Austausch besonders relevante Daten sind:
- Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung,
Projektspannungen.
- Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und
Raumname.
- Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor.
- Stromkreis: interne UUID, BMK, Anzeigename, Bereich, Schutz-, Kabel-,
Spannungs-, Steuerungs-, Status- und Bemerkungsdaten.
- Gerätezeile: interne UUID, optionale Projektgeräteverknüpfung, Name,
Anzeigename, Phasenart, Anschlussart, Kostengruppe, Kategorie, Geschoss,
Raum, Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi und Bemerkung.
- Projektgerät: interne UUID sowie die wiederverwendbaren Gerätewerte.
Die Phasenart ist fachlich `1-phasig` oder `3-phasig`. Die Spannung wird daraus
und aus den Projekteinstellungen abgeleitet; freie Sonderspannungen sind im
aktuellen Produkt nicht vorgesehen.
## Aktuelle technische Architektur
- Frontend: Next.js App Router, React und TypeScript unter `src/app` und
`src/frontend`.
- API: Express und Zod unter `src/server`.
- Fachregeln und typisierte Commands: `src/domain`.
- Persistenz: SQLite, Drizzle und Repository-Adapter unter `src/db`.
- Laufzeitkomposition konkreter Repositories:
`src/server/composition`.
- Lokaler Betrieb: Browser/Next.js auf Port 3001, Express-API auf Port 3000,
SQLite-Datei unter `data/leistungsbilanz.db`.
- Docker Compose wird für die lokale Entwicklung unterstützt.
- PostgreSQL und Mehrbenutzerbetrieb sind noch nicht umgesetzt.
Der zentrale Editor liegt unter:
```text
src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx
src/frontend/components/circuit-tree-editor.tsx
```
Frontend-Aktionen werden als validierte API-Commands ausgeführt. Fachregeln
liegen in Domain-Services; konkrete SQLite-Zugriffe bleiben in
Persistenzadaptern. Neue Revit-Fachlogik darf deshalb nicht direkt von
`better-sqlite3`, React-Komponenten oder globalen Datenbankinstanzen abhängen.
## Revisionen, Undo/Redo und Sicherungspunkte
Alle normalen projektbezogenen Änderungen laufen über eine gemeinsame
Command- und Transaktionsgrenze:
```text
Fachänderung + inverses Kommando + Revision + Undo/Redo-Übergang
```
Diese Bestandteile werden atomar geschrieben oder vollständig zurückgerollt.
Jedes Projekt besitzt eine monoton steigende `currentRevision`. Schreibbefehle
geben die erwartete Revision mit, damit veraltete Clients keine neueren
Änderungen überschreiben.
Undo/Redo ist projektweit persistent und funktioniert nach Reload oder
Anwendungsneustart. Zusätzlich gibt es:
- eine unveränderliche Revisions-Timeline;
- benannte und automatische logische Projektsnapshots;
- wiederherstellbare Snapshots mit Prüfsumme;
- einen portablen, versionierten JSON-Projekttransfer.
Ein angewendeter Revit-Import muss später dieselbe Integritätsgrenze verwenden:
Die bestätigten Änderungen sollen als ein nachvollziehbarer Projekt-Command
beziehungsweise als ein fachlich definierter Änderungssatz atomar,
revisioniert und rückgängig machbar sein.
## Klare Abgrenzung zum vorhandenen JSON-Projekttransfer
Der vorhandene JSON-Export/-Import überträgt den vollständigen internen
Projektzustand. Er dient Sicherung, Wiederherstellung und Duplizierung eines
Projekts innerhalb von Leistungsbilanz.
Dieser Projekttransfer ist **kein** Revit-Austauschformat. Er enthält interne
Strukturen und UUIDs und soll nicht ohne bewusste Entscheidung als Grundlage
für CSV-Dateien oder Revit-Parameter verwendet werden.
Der zukünftige Revit-Workflow soll dagegen externe Modellobjekte anhand stabiler
externer Identitäten wiedererkennen, ausgewählte Planungswerte zuordnen und
Ergebnisse so exportieren, dass Revit sie wieder den richtigen Objekten
zuweisen kann.
## Bereits vereinbarte Leitplanken für den externen Modellaustausch
Diese Punkte gelten als Architekturvorgaben, noch nicht als fertiges
Implementierungsdesign:
- Ein Import schreibt niemals ungeprüft direkt in Stromkreise oder
Gerätezeilen.
- Eingelesene Daten landen zuerst in einem Staging-/Vorschaubereich.
- Vor der Übernahme werden neue, geänderte, fehlende, doppelte und
widersprüchliche Objekte angezeigt.
- Wiederholte Importe müssen Objekte deterministisch anhand der externen Quelle
und einer stabilen Identität, voraussichtlich IFCGUID, erkennen.
- Die Eindeutigkeit einer IFCGUID ist mindestens auf Projekt und externe
Modellquelle begrenzt; die genaue Quellendefinition ist noch zu klären.
- Importierte Quellwerte und lokal in Leistungsbilanz geplante Werte müssen
unterscheidbar bleiben.
- Lokale Planungswerte dürfen niemals still überschrieben werden.
- Verknüpfungen zwischen externen Objekten und internen Fachobjekten müssen
ausdrücklich modelliert werden.
- Fehlende oder widersprüchliche Identitäten werden gemeldet und niemals
automatisch einem anderen Objekt zugeordnet.
- Ein Export bewahrt IFCGUID und erforderliche Quellidentitäten, damit Revit
die Werte deterministisch zurückschreiben kann.
- Ein Export bezieht sich auf eine bekannte Projektrevision.
- Große Datenmengen allein erzwingen noch keinen Wechsel von SQLite zu
PostgreSQL. PostgreSQL wird relevant bei Mehrbenutzerbetrieb, zentralem
Serverbetrieb oder unabhängigen Hintergrundjobs.
Als mögliche, aber noch nicht beschlossene Konzepte wurden bisher genannt:
- `ExternalModelSource`
- `ExternalImportBatch`
- `ExternalModelObject`
- `ExternalObjectLink`
- `ExternalParameterMapping`
- `ExternalExportBatch`
Diese Namen beschreiben Verantwortlichkeiten. Das andere LLM darf sie
hinterfragen und verbessern, soll sie aber nicht als bereits vorhandene
Tabellen behandeln.
## Auftrag an das andere LLM
Erarbeite die Anforderungen dialogorientiert mit dem Anwender. Beginne mit dem
realen Ablauf in Revit und kläre danach schrittweise mindestens:
1. Welche Revit-Objekte beziehungsweise Kategorien werden exportiert?
2. Wie entsteht die Ausgangsdatei in Revit und wie sieht sie technisch aus
(CSV-Dialekt, Trennzeichen, Encoding, Dezimalformat, Kopfzeilen,
Revit-Versionen)?
3. Welche Identitäten stehen zur Verfügung: IFCGUID, Revit `ElementId`,
Typ-ID, Modell-/Dateikennung oder weitere Schlüssel?
4. Wie werden Hauptmodell, Teilmodelle, verknüpfte Modelle, Modellkopien und
aktualisierte Revit-Dateien unterschieden?
5. Welche Parameter kommen aus Revit und welche Werte werden ausschließlich in
Leistungsbilanz geplant?
6. Welche internen Ziele können externe Objekte erhalten: Projektgerät,
Gerätezeile, Stromkreis, Raum, Verteilung oder weitere Entitäten?
7. Werden externe Objekte einzeln oder gesammelt einem Stromkreis,
Projektgerät, Raum oder einer Verteilung zugeordnet?
8. Welche Werte sollen nach Revit zurückgeschrieben werden, in welchen
Einheiten und unter welchen Revit-Parameternamen?
9. Muss die ursprüngliche Datei einschließlich unbekannter Spalten,
Zeilenreihenfolge und Formatierung erhalten werden, oder darf eine neue
Rückgabedatei erzeugt werden?
10. Wie sollen Spalten-/Parametermappings erstellt, gespeichert,
wiederverwendet und zwischen Projekten geteilt werden?
11. Was gilt bei erneutem Import für neue, geänderte, gelöschte, verschobene,
doppelte oder nicht mehr sichtbare Revit-Objekte?
12. Wer gewinnt bei Konflikten zwischen neuem Revit-Wert und lokaler Änderung?
Welche Konflikte dürfen automatisch gelöst werden und welche benötigen
eine Auswahl?
13. Welche Vorschau, Filter, Sammelaktionen und Bestätigungen benötigt der
Anwender vor der Übernahme?
14. Welche fachlichen Validierungen und Pflichtfelder müssen eine Übernahme
verhindern oder nur eine Warnung erzeugen?
15. Welche Dateigrößen, Objektzahlen und Laufzeiten sind realistisch?
16. Müssen Import und Export synchron im Browser laufen oder werden später
Hintergrundjobs benötigt?
17. Welche Informationen müssen für Nachvollziehbarkeit, Undo/Redo,
Versionshistorie und Support gespeichert werden?
18. Welche konkreten End-to-End-Beispiele und Fehlerfälle dienen als
Abnahmekriterien?
Stelle Rückfragen in kleinen, zusammenhängenden Blöcken. Unterscheide in deinen
Notizen ausdrücklich:
- vom Anwender bestätigte Anforderungen;
- sinnvolle Vorschläge;
- offene Entscheidungen;
- technische Folgerungen;
- bewusst nicht betrachtete spätere Ausbaustufen.
## Erwartetes Ergebnis der Anforderungsrunde
Erstelle am Ende eine neue Arbeitsanweisung für das implementierende LLM. Sie
soll mindestens enthalten:
- Ziel, Umfang und ausdrücklich ausgeschlossene Funktionen;
- vollständigen Anwenderworkflow für Erstimport, Zuordnung, Folgeimport und
Rückexport;
- vereinbartes Dateiformat mit einem anonymisierten Beispiel;
- Feld- und Einheitenmapping einschließlich Richtung und Datenhoheit;
- Identitäts-, Matching- und Quellmodell;
- Regeln für neue, geänderte, fehlende und doppelte Objekte;
- Konflikt-, Validierungs- und Freigaberegeln;
- UI-Ablauf für Staging, Vorschau, Zuordnung und Bestätigung;
- Anforderungen an Revision, Atomarität, Undo/Redo und Audit;
- vorgeschlagene Modul- und Persistenzgrenzen, ohne bestehende
Architekturregeln zu umgehen;
- schrittweise Implementierungsphasen mit überprüfbaren Abnahmekriterien;
- offene Punkte, die vor dem jeweiligen Implementierungsschritt geklärt werden
müssen.
Die Arbeitsanweisung soll noch keinen Code enthalten. Sie muss klar markieren,
welche Entscheidungen der Anwender tatsächlich getroffen hat und welche Teile
nur Architekturvorschläge des LLM sind.
## Weiterführende Projektdokumente
- `AGENTS.md` verbindliche Domänen- und Implementierungsregeln
- `docs/current-architecture.md` vollständiger aktueller Laufzeit- und
Command-Pfad
- `docs/project-history-and-external-model-architecture.md` bisherige
Zukunftsrichtung für Versionierung, externes Modell und PostgreSQL
- `docs/spec/01-domain-context.md` fachlicher Kontext
- `docs/spec/03-data-model-concept.md` Datenmodellkonzept
- `docs/spec/05-linked-devices-and-sync.md` Regeln für Projektgeräte und
kontrollierte Synchronisierung
- `docs/spec/07-implementation-phases-todo.md` Roadmap, insbesondere Phase 13
- `docs/circuit-list-editor-known-limitations.md` noch nicht implementierte
Funktionen
+289
View File
@@ -300,3 +300,292 @@ Acceptance criteria:
- undo/redo does not disrupt the current reading position
- the editor remains usable without overflowing the browser layout
- active filters are easy to understand, change and clear
## Phase 11: Persistence and Transaction Foundation
Status: Complete.
Goal:
Prepare the current SQLite implementation for persistent history and a later PostgreSQL adapter without changing user-facing behavior.
Tasks:
- add real SQLite integration tests for transaction commit and rollback
- introduce an injectable transaction / unit-of-work boundary
- remove direct database-client access from domain services
- treat synchronous SQLite transactions as an adapter detail
- remove dead persistence methods after coverage exists
- verify database backup and restore independently from project history
Acceptance criteria:
- critical multi-write commands roll back completely in a real database test
- domain command behavior is not coupled to `better-sqlite3`
- adding a PostgreSQL persistence adapter does not require rewriting editor domain rules
Implemented foundation:
- database contexts can be created independently from the production singleton
- SQLite foreign-key enforcement is enabled explicitly for every context
- the read-only distribution-board repository receives its database dependency explicitly
- distribution-board setup uses its persistent command repository with real
in-memory SQLite commit and rollback coverage using production migrations
- database backups use SQLite's online backup API and include committed WAL data
- every backup is opened independently and checked for integrity and foreign-key violations
- an integration test restores the backup into a separate database and verifies its snapshot contents
- circuit-device-row creation/deletion and reserve-state changes use an explicitly injected transaction store
- a new circuit and all of its initial device rows use the same transaction store
- device-row moves, reserve-state updates and optional target creation share one transaction
- project-device synchronization, disconnect and reconnect 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
- the legacy consumer migration service depends only on narrow domain ports;
all concrete repositories are composed at the script entry point
- legacy circuit, row, mapping and report writes have real late-failure rollback coverage
- circuit and device-row persistence value mapping is separated from the general repositories
- obsolete direct Circuit, CircuitDeviceRow, CircuitList, DistributionBoard and
Project repository writes are removed; integration fixtures live under
`tests/support` instead of production repositories
- all supported runtime project-command stores, including complete snapshot
restore, share one tested transaction wrapper for domain mutation, revision
append and history transition; its applied-forward variant retains derived
CircuitDeviceRow override metadata
- low-level revision append persistence is tested directly; the unused
standalone runtime revision repository and store interface are removed
- runtime project-device synchronization and circuit-numbering services depend
on narrow domain readers; server composition injects the SQLite repositories
- all general application repositories require an explicit database context;
server composition owns their runtime instances and controllers contain no
direct global SQLite access
- TypeScript maintenance and upgrade scripts have a dedicated no-emit
typecheck, including all referenced application modules
- the legacy consumer UI and application read/write endpoints are removed after verified data cutover
- retained legacy rows are accessible only through explicit database upgrade tooling
- project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields
## Phase 12: Project Revisions and Persistent Undo / Redo
Goal:
Persist project-wide change history and restore points across reloads and application restarts.
Tasks:
- [x] add project-scoped monotonic revision storage
- [x] add immutable append-only server-side change-set storage with versioned
forward/inverse payloads
- [x] add optimistic checks when appending a revision
- [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
- [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:
- projects start at revision zero and advance monotonically
- each revision has one separately stored logical change set
- forward and inverse changes use complete, versioned JSON command envelopes
- invalid or lossy command payloads are rejected before persistence
- an internal typed `circuit.update` command generates its inverse from the
persisted pre-command state
- circuit update, inverse command, change set and revision share one SQLite
transaction with stale-command and late-failure rollback coverage
- an internal typed `circuit-device-row.update` command uses the same boundary,
records derived ProjectDevice override metadata and rejects cross-project
rows, device links and rooms
- relational project-wide undo/redo stacks persist eligible original change
sets, enforce LIFO order and clear redo on a new user command
- stack transitions are part of the domain/revision transaction and have
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
- the circuit tree exposes `currentRevision`; existing Circuit and
CircuitDeviceRow cell edits use those optimistic commands and project-wide
undo/redo instead of direct field-update PATCH routes
- typed CircuitDeviceRow insert/delete commands preserve complete row snapshots
and stable ids; row mutation, circuit reserve state, revision and history
stack transition commit or roll back together
- typed Circuit insert/delete commands treat the circuit and all device rows as
one block, preserve every stable id and never renumber neighbouring circuits
- standalone Circuit/CircuitDeviceRow insert/delete editor paths use those
commands with stable client-generated UUIDs and project-wide undo/redo; the
obsolete direct structure POST and CircuitDeviceRow DELETE routes are removed
- typed CircuitDeviceRow move commands persist exact old/new circuit and sort
positions for single or multi-row moves between existing circuits and derive
affected reserve states in the same transaction
- placeholder-target moves persist one complete generated circuit identity and
all row positions atomically; undo restores every source and removes only an
unchanged generated circuit, without renumbering
- complete section reorders persist every expected and target circuit
`sortOrder`; undo/redo changes neither equipment identifiers nor device rows
- explicit section renumber commands persist every expected and target
equipment identifier, apply swaps collision-safely and restore exact prior
identifiers without changing sort positions or device rows
- ProjectDevice row synchronization, disconnect and reconnect persist complete
expected/target sync snapshots in one atomic multi-row command; undo restores
local values, links and override metadata without silent overwrites
- canonical ProjectDevice field updates persist independently with exact
inverses and never overwrite linked CircuitDeviceRow values implicitly
- ProjectDevice insert/delete commands preserve stable device ids and restore
previously linked rows only from complete unchanged disconnected snapshots
- ProjectDevice create, update, delete and global-to-project copy endpoints use
these commands, require `expectedRevision` and return the new history state
- 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
- floor and room setup use `project-floor.insert` and `project-room.insert`
with stable ids; their inverses refuse assigned or referenced records and the
former direct repository create methods are removed
- revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction
- a stale expected revision produces no history writes
- integration coverage verifies sequential revisions, stale-command rejection
and rollback after a forced late persistence failure
ProjectDevice CRUD, synchronization and disconnect on the project page and
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:
- undo/redo remains available after a page reload
- every logical command and inverse is atomic and auditable
- restoring an older state does not delete intervening history
- concurrent stale commands cannot silently overwrite newer project state
## Phase 13: External Model Round-Trip
Goal:
Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format.
Tasks:
- model external sources, import batches and stable IFCGUID objects
- stage and validate imports before applying them
- show additions, changes, missing objects and conflicts
- link external objects explicitly to project-domain entities
- apply an import as one auditable project revision
- configure inbound and outbound parameter mappings
- export against a known project revision while preserving IFCGUID
Acceptance criteria:
- repeated imports match objects deterministically by source and IFCGUID
- imports never silently overwrite local planning values
- users approve the diff before it changes the project
- exported data can be assigned back to the correct Revit objects
PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration.
## Phase 14: Documentation and Collaboration Handoff
Goal:
After the legacy consumer path has been removed, provide one accurate entry point for developers, contributors and LLM-assisted work.
Timing:
- perform the final rewrite after legacy cutover so the documentation describes only the supported architecture
- update documentation incrementally during earlier phases, but do not present transitional paths as the long-term design
README target structure:
- short project purpose and current maturity/status
- supported domain workflow at a high level
- technology stack
- prerequisites
- recommended Docker development quick start
- direct local development start
- test, type-check and build commands
- database persistence, migration, backup and restore basics
- production deployment/install instructions once a production setup exists
- concise documentation map
- contribution entry point
README content to remove or relocate:
- historical requirement checklists
- file-by-file implementation status from early prototypes
- obsolete legacy consumer architecture
- long domain specifications already maintained in `docs/`
- broken links and claims that are not verified by the current code
Documentation tasks:
- add a `docs/README.md` index with current, future and archived documents clearly separated
- keep current architecture, domain invariants, API behavior and interaction behavior synchronized with code
- archive superseded initial requirements under `docs/archive/` when they are still useful for traceability
- remove obsolete documentation that has no remaining historical or operational value
- update `AGENTS.md` with current entry points, supported/deprecated paths and forward architecture constraints
- add a concise contributor workflow for branches, migrations, tests and commits
- document how a colleague creates an empty local database and optionally loads safe sample data
- avoid duplicating the same architecture facts across README, `AGENTS.md` and detailed docs; link to one source of truth
Deployment documentation requirements:
- distinguish development Compose from production deployment
- do not label the hot-reload development stack as a production setup
- document required environment variables, ports, health checks, persistent storage and migration behavior
- verify backup and restore procedures
Acceptance criteria:
- a colleague can clone the repository and start a clean development environment using only the README
- a contributor can identify the supported architecture and relevant code entry points without reading historical specs
- an LLM receives domain invariants, current module boundaries and deferred architecture direction without relying on stale legacy files
- every README command and internal documentation link is verified
- production instructions describe a real tested deployment path or clearly state that none is supported yet
Implemented handoff:
- README is a concise setup and project entry point without prototype status lists
- `docs/README.md` separates current, operational, future, specification and archived material
- current runtime, module boundaries and upgrade-only legacy data are documented explicitly
- contributor workflow covers clean databases, safe demo data, migrations, tests and commits
- development Compose is clearly separated from the currently unsupported production deployment
- `AGENTS.md` points LLM-assisted work to supported code paths and deferred architecture
- obsolete frontend placeholder documents were removed and early Codex prompts were archived
+94
View File
@@ -0,0 +1,94 @@
# Current Product Backlog
This document records the next agreed product improvements. It describes
requirements and intended sequencing, not proof of implementation.
## Circuit List Editor
- Collect the remaining editor interaction issues before changing additional
behavior.
## First Release Baseline
Complete this immediately before the first supported release, once the schema
and initial feature set are frozen:
- Decide explicitly whether pre-release databases, portable JSON exports,
logical snapshots and persisted command histories remain supported.
- Create one clean baseline migration that builds the complete release schema
on an empty database.
- Replace the active development migration chain and Drizzle metadata only if
no released installation depends on it.
- Remove upgrade-only database, snapshot and command compatibility code only
for formats that are explicitly declared unsupported.
- Preserve the pre-release migration history in Git history or a release tag.
- Verify installation, migrations, application startup and core workflows
against a completely empty database.
- Mark the resulting compatibility boundary with the first release tag.
- Treat every migration published after that release as immutable.
## Project History
- [x] Every listed logical snapshot, including automatic snapshots, must be
explicitly restorable from the project history UI.
- [x] Show which project change produced a snapshot. Prefer immutable revision
metadata over storing a second free-form description of the same change.
- [x] Keep restoration auditable as a new project revision and preserve persistent
undo/redo.
## Distribution Boards
- [x] Assign an optional project floor to a distribution board.
- [x] Store a supply classification for each distribution board:
- `AV` (Allgemeine Stromversorgung)
- `SV` (Sicherheitsstromversorgung)
- `EV` (Ersatzstromversorgung)
- `USV` (Unterbrechungsfreie Stromversorgung)
- `MSR` (Mess-, Steuerungs- und Regelungstechnik)
- `SiBe` (Sicherheitsbeleuchtung)
- [x] Let project settings select which catalog supply types are used in a
project. Distribution-board dialogs offer only that selection; a type in
use cannot be disabled.
- [x] Include both fields in project snapshots and portable project
export/import.
- [x] Persist changes through project commands so they remain undoable after a
restart.
## Device Voltage Derivation
- [x] Derive voltage from the selected phase type and the project settings:
- single-phase uses the project's single-phase voltage;
- three-phase uses the project's three-phase voltage.
- [x] Voltage is not editable on global devices, project devices or circuits.
Global devices store only their phase; copying one into a project derives
the target project's voltage.
- [x] Project-device voltage follows its phase. Circuit voltage follows the
section phase; an unassigned circuit is three-phase only when all assigned
device rows with a valid phase are three-phase.
- [x] Changing project voltage settings updates all project devices and circuits
atomically in the same persistent Undo/Redo step.
- [x] Database migration `0019` and snapshot schema version `5` normalize older
stored values. Version-four and older imports remain supported and are
normalized while being upgraded.
## Circuit List Power Summary
- [x] Show the total power of every circuit section.
- [x] Show the unadjusted total power of the complete distribution board.
- [x] Store a distribution-board simultaneity factor between zero and one in
the distribution-board settings.
- [x] Show the distribution-board total after applying that factor.
- [x] Preserve the factor in project commands, snapshots and portable project
transfers.
- [x] Keep column visibility and order as a project-specific UI preference so
all distribution boards in the same project open with the same columns.
## Recommended Sequence
1. [x] Distribution-board floor and supply fields.
2. [x] Device voltage derivation without overrides.
3. [x] Snapshot-to-revision descriptions and history presentation.
4. [x] Distribution-board power summary and project column layout.
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
must not be inferred from these tasks.
+6 -7
View File
@@ -1,7 +1,7 @@
{
"name": "leistungsbilanz",
"version": "1.0.0",
"description": "",
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
"main": "dist/server/index.js",
"scripts": {
"dev": "npm run dev:api",
@@ -13,17 +13,16 @@
"build": "npm run build:api",
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
"start": "node dist/server/index.js",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.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-grid-projection.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.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-grid-projection.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.service.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-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.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-overview-import.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.service.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-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.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-overview-import.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": "node scripts/db-backup.js",
"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": "",
+4 -3
View File
@@ -1,10 +1,11 @@
import { db } from "../src/db/client.js";
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
const projectRepository = new ProjectRepository();
const circuitListRepository = new CircuitListRepository();
const circuitSectionRepository = new CircuitSectionRepository();
const projectRepository = new ProjectRepository(db);
const circuitListRepository = new CircuitListRepository(db);
const circuitSectionRepository = new CircuitSectionRepository(db);
async function run() {
const projects = await projectRepository.list();
-19
View File
@@ -1,19 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const dataDir = path.resolve("data");
const source = path.join(dataDir, "leistungsbilanz.db");
if (!fs.existsSync(source)) {
console.error(`Database file not found: ${source}`);
process.exit(1);
}
const backupDir = path.join(dataDir, "backups");
fs.mkdirSync(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const target = path.join(backupDir, `leistungsbilanz-${timestamp}.db`);
fs.copyFileSync(source, target);
console.log(`Backup created: ${target}`);
+27
View File
@@ -0,0 +1,27 @@
import path from "node:path";
import { createVerifiedDatabaseBackup } from "../src/db/database-backup.js";
const dataDirectory = path.resolve("data");
const source = path.join(dataDirectory, "leistungsbilanz.db");
const backupDirectory = path.join(dataDirectory, "backups");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const destination = path.join(
backupDirectory,
`leistungsbilanz-${timestamp}.db`
);
async function run() {
const verification = await createVerifiedDatabaseBackup(source, destination);
console.log(`Backup created and verified: ${destination}`);
console.log(
`Integrity: ${verification.integrityCheck}; foreign-key violations: ${verification.foreignKeyViolations}`
);
}
run().catch((error) => {
console.error(
"Database backup failed:",
error instanceof Error ? error.message : error
);
process.exit(1);
});
+28 -3
View File
@@ -1,10 +1,22 @@
import { db } from "../src/db/client.js";
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
import { RoomRepository } from "../src/db/repositories/room.repository.js";
import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js";
const projectRepository = new ProjectRepository();
const circuitListRepository = new CircuitListRepository();
const migrationService = new LegacyConsumerMigrationService();
const projectRepository = new ProjectRepository(db);
const circuitListRepository = new CircuitListRepository(db);
const migrationRepository = new LegacyConsumerMigrationRepository(db);
const migrationService = new LegacyConsumerMigrationService({
circuitListReader: circuitListRepository,
sectionStore: new CircuitSectionRepository(db),
circuitReader: new CircuitRepository(db),
roomReader: new RoomRepository(db),
migrationStore: migrationRepository,
});
async function run() {
const projects = await projectRepository.list();
@@ -26,8 +38,21 @@ async function run() {
}
}
const unmigratedConsumers =
await migrationRepository.listUnmigratedConsumers();
if (unmigratedConsumers.length > 0) {
const withoutCircuitList = unmigratedConsumers.filter(
(consumer) => !consumer.circuitListId
).length;
throw new Error(
`Cutover verification failed: ${unmigratedConsumers.length} legacy consumer(s) remain unmigrated` +
` (${withoutCircuitList} without a circuit list).`
);
}
console.log("Legacy consumer migration summary:");
console.table(reports);
console.log("Cutover verification passed: all legacy consumers are mapped.");
}
run().catch((error) => {
+69
View File
@@ -33,17 +33,60 @@ const projectDeviceColumns = new Set(
const missingProjectDeviceColumns = requiredProjectDeviceColumns.filter(
(name) => !projectDeviceColumns.has(name)
);
const removedProjectDeviceColumns = [
"installed_power_per_unit_kw",
"demand_factor",
"phase_count",
"power_factor",
"note",
];
const remainingRemovedProjectDeviceColumns = removedProjectDeviceColumns.filter(
(name) => projectDeviceColumns.has(name)
);
const requiredCircuitColumns = ["control_requirement"];
const circuitColumns = new Set(
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
);
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
const requiredProjectColumns = [
"internal_project_number",
"external_project_number",
"building_owner",
"description",
"enabled_distribution_board_supply_types",
];
const projectColumns = new Set(
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
);
const missingProjectColumns = requiredProjectColumns.filter(
(name) => !projectColumns.has(name)
);
const requiredDistributionBoardColumns = ["floor_id", "supply_type"];
const distributionBoardColumns = new Set(
db
.prepare("PRAGMA table_info(distribution_boards)")
.all()
.map((column) => column.name)
);
const missingDistributionBoardColumns =
requiredDistributionBoardColumns.filter(
(name) => !distributionBoardColumns.has(name)
);
const integrityCheck = db.pragma("integrity_check", { simple: true });
const foreignKeyViolations = db.pragma("foreign_key_check");
console.log("Database:", dbPath);
console.log("Required tables:", requiredTables.join(", "));
console.log("Existing tables:", [...existing].join(", ") || "(none)");
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
console.log("Required project columns:", requiredProjectColumns.join(", "));
console.log(
"Required distribution-board columns:",
requiredDistributionBoardColumns.join(", ")
);
console.log("Integrity:", integrityCheck);
console.log("Foreign-key violations:", foreignKeyViolations.length);
if (missing.length > 0) {
console.error("Missing tables:", missing.join(", "));
@@ -55,9 +98,35 @@ if (missingProjectDeviceColumns.length > 0) {
process.exit(1);
}
if (remainingRemovedProjectDeviceColumns.length > 0) {
console.error(
"Transitional project-device columns still present:",
remainingRemovedProjectDeviceColumns.join(", ")
);
process.exit(1);
}
if (missingCircuitColumns.length > 0) {
console.error("Missing circuit columns:", missingCircuitColumns.join(", "));
process.exit(1);
}
if (missingProjectColumns.length > 0) {
console.error("Missing project columns:", missingProjectColumns.join(", "));
process.exit(1);
}
if (missingDistributionBoardColumns.length > 0) {
console.error(
"Missing distribution-board columns:",
missingDistributionBoardColumns.join(", ")
);
process.exit(1);
}
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
console.error("Database integrity or foreign-key verification failed.");
process.exit(1);
}
console.log("Circuit-first schema verification passed.");
-40
View File
@@ -1,40 +0,0 @@
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.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 rowRepository = new CircuitDeviceRowRepository();
const circuit = await circuitRepository.findById(circuitId);
if (!circuit) {
console.error(`Circuit not found: ${circuitId}`);
process.exit(1);
}
const rowCount = await rowRepository.countByCircuit(circuitId);
const createdRowId = await rowRepository.create({
circuitId,
sortOrder: (rowCount + 1) * 10,
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);
});
-25
View File
@@ -1,25 +0,0 @@
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.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 row = await rowRepository.findById(rowId);
if (!row) {
console.error(`Row not found: ${rowId}`);
process.exit(1);
}
await rowRepository.delete(rowId);
console.log(`Deleted row id: ${rowId}`);
}
run().catch((error) => {
console.error("Failed to delete row:", error);
process.exit(1);
});
+115 -14
View File
@@ -71,6 +71,13 @@ body {
opacity: 0.45;
}
.editor-toolbar .project-device-drawer-toggle {
border-color: #2563eb;
background: #2563eb;
color: #fff;
font-weight: 600;
}
.active-view-summary {
display: flex;
flex-wrap: wrap;
@@ -109,6 +116,39 @@ body {
font-weight: 600;
}
.distribution-power-summary {
display: grid;
grid-template-columns: repeat(3, minmax(12rem, 1fr));
gap: 0.5rem;
padding: 0.55rem;
border: 1px solid #cbd5e1;
border-radius: 5px;
background: #f8fafc;
}
.distribution-power-summary > div {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.distribution-power-summary span {
color: #475569;
font-size: 0.74rem;
line-height: 1.2;
}
.distribution-power-summary strong {
color: #172033;
font-size: 0.95rem;
}
@media (max-width: 900px) {
.distribution-power-summary {
grid-template-columns: 1fr;
}
}
.column-settings-menu {
position: absolute;
z-index: 9;
@@ -271,12 +311,10 @@ body {
}
.tree-editor-layout {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: 0.75rem;
display: block;
min-height: 560px;
min-width: 0;
align-items: start;
position: relative;
}
.project-device-sidebar {
@@ -354,7 +392,7 @@ body {
.tree-grid {
width: max-content;
min-width: 100%;
min-width: 0;
border-collapse: collapse;
font-size: 0.9rem;
}
@@ -362,37 +400,50 @@ body {
.tree-grid th,
.tree-grid td {
border: 1px solid #e4e9f2;
padding: 0.35rem 0.5rem;
white-space: nowrap;
padding: 0.35rem 0.4rem;
vertical-align: middle;
}
.tree-grid th {
width: 1px;
background: #f4f7fb;
position: sticky;
top: 0;
z-index: 2;
vertical-align: top;
white-space: normal;
}
.tree-grid td {
white-space: nowrap;
}
.tree-grid .header-cell {
display: flex;
align-items: center;
gap: 0.3rem;
flex-direction: column;
align-items: stretch;
gap: 0.2rem;
min-width: 0;
}
.tree-grid .header-filter-btn {
display: block;
width: 100%;
border: 1px solid #c4cddc;
background: #fff;
border-radius: 3px;
font-size: 0.75rem;
padding: 0.15rem 0.35rem;
white-space: nowrap;
}
.tree-grid .header-sort-btn {
display: inline-flex;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
width: 100%;
min-width: 0;
border: 0;
background: transparent;
padding: 0;
@@ -400,10 +451,46 @@ body {
font: inherit;
font-size: 0.78rem;
font-weight: 600;
line-height: 1.15;
overflow-wrap: anywhere;
white-space: normal;
text-align: left;
cursor: pointer;
}
.project-device-drawer {
position: fixed;
z-index: 1040;
top: 5.5rem;
right: 1rem;
left: auto;
width: min(360px, calc(100vw - 2rem));
max-height: calc(100vh - 7rem);
overflow: auto;
border-radius: 0.5rem;
box-shadow: 0 0.75rem 2rem rgba(31, 41, 55, 0.22);
}
.project-device-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.project-device-drawer-header span {
color: #6b7280;
font-size: 0.75rem;
}
.project-device-drawer-header button {
border: 1px solid #c4cddc;
background: #fff;
border-radius: 4px;
padding: 0.25rem 0.4rem;
font-size: 0.78rem;
}
.tree-grid .header-sort-btn:hover span:first-child,
.tree-grid .header-sort-btn:focus-visible span:first-child {
text-decoration: underline;
@@ -641,6 +728,18 @@ body {
outline-offset: -2px;
}
.tree-grid .section-title {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.tree-grid .section-title span {
color: #475569;
font-size: 0.78rem;
font-weight: 500;
}
.tree-grid .cell-invalid {
outline: 2px solid #c2410c;
outline-offset: -2px;
@@ -651,7 +750,8 @@ body {
border-color: #c2410c;
}
.tree-grid input {
.tree-grid input,
.tree-grid select {
width: 100%;
min-width: 5rem;
border: 1px solid #9fb6e0;
@@ -783,8 +883,9 @@ body {
margin: 0;
}
@media (max-width: 1200px) {
.tree-editor-layout {
grid-template-columns: 1fr;
@media (max-width: 720px) {
.project-device-drawer {
top: 4.5rem;
max-height: calc(100vh - 5.5rem);
}
}
@@ -8,8 +8,8 @@ export default function CircuitTreeEditPage() {
const params = useParams<{ projectId: string; circuitListId: string }>();
return (
<main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-3">
<main className="container-fluid px-3 px-xxl-4 py-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div>
<h1 className="h4 mb-1">Stromkreisliste</h1>
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
@@ -24,9 +24,6 @@ export default function CircuitTreeEditPage() {
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
Zum Projekt
</Link>
<Link href={`/projects/${params.projectId}/circuit-lists`} className="btn btn-outline-secondary btn-sm">
Bisherige Listenansicht
</Link>
</div>
</div>
<CircuitTreeEditor projectId={params.projectId} circuitListId={params.circuitListId} />
@@ -25,10 +25,10 @@ export default function CircuitTreePreviewPage() {
Editor öffnen
</Link>
<Link
href={`/projects/${projectId}/circuit-lists`}
href={`/projects/${projectId}`}
className="btn btn-outline-secondary btn-sm"
>
Zurück zur bisherigen Listenansicht
Zum Projekt
</Link>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+137 -11
View File
@@ -6,6 +6,7 @@ import {
createGlobalDevice,
createProject,
deleteGlobalDevice,
importProjectTransferAsNew,
listGlobalDevices,
listProjects,
updateGlobalDevice,
@@ -23,7 +24,6 @@ const emptyGlobalDevice: CreateGlobalDeviceInput = {
quantity: 1,
installedPowerPerUnitKw: 0.1,
demandFactor: 1,
voltageV: 230,
phaseCount: 1,
powerFactor: 1,
note: "",
@@ -48,13 +48,22 @@ export default function ProjectsPage() {
quantity: "1",
installedPowerPerUnitKw: "0.1",
demandFactor: "1",
voltageV: "230",
phaseCount: "1",
powerFactor: "1",
note: "",
});
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [projectImport, setProjectImport] = useState<unknown>(null);
const [projectImportFilename, setProjectImportFilename] = useState("");
const [projectImportInputKey, setProjectImportInputKey] = useState(0);
const [projectImportFileError, setProjectImportFileError] = useState<
string | null
>(null);
const [importedProject, setImportedProject] = useState<{
projectId: string;
name: string;
} | null>(null);
async function loadData() {
setError(null);
@@ -87,6 +96,49 @@ export default function ProjectsPage() {
}
}
async function handleProjectImportFile(file: File | undefined) {
setProjectImport(null);
setProjectImportFilename("");
setProjectImportFileError(null);
setImportedProject(null);
if (!file) {
return;
}
try {
setProjectImport(JSON.parse(await file.text()) as unknown);
setProjectImportFilename(file.name);
} catch {
setProjectImportFileError(
"Die ausgewählte Datei enthält kein gültiges JSON."
);
}
}
async function handleImportProject() {
if (projectImport === null) {
return;
}
setIsSaving(true);
setError(null);
setImportedProject(null);
try {
const imported = await importProjectTransferAsNew(projectImport);
setProjects(await listProjects());
setImportedProject(imported);
setProjectImport(null);
setProjectImportFilename("");
setProjectImportInputKey((current) => current + 1);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Projektdatei konnte nicht importiert werden."
);
} finally {
setIsSaving(false);
}
}
async function handleCreateGlobalDevice(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!globalDeviceForm.name.trim()) {
@@ -99,7 +151,6 @@ export default function ProjectsPage() {
quantity: Number(globalDeviceForm.quantity),
installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw),
demandFactor: Number(globalDeviceForm.demandFactor),
voltageV: toOptionalNumber(globalDeviceForm.voltageV),
phaseCount: globalDeviceForm.phaseCount === "3" ? 3 : 1,
powerFactor: toOptionalNumber(globalDeviceForm.powerFactor),
note: globalDeviceForm.note.trim() || undefined,
@@ -116,7 +167,6 @@ export default function ProjectsPage() {
quantity: String(emptyGlobalDevice.quantity),
installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw),
demandFactor: String(emptyGlobalDevice.demandFactor),
voltageV: String(emptyGlobalDevice.voltageV ?? ""),
phaseCount: String(emptyGlobalDevice.phaseCount ?? 1),
powerFactor: String(emptyGlobalDevice.powerFactor ?? ""),
note: emptyGlobalDevice.note ?? "",
@@ -143,7 +193,7 @@ export default function ProjectsPage() {
async function handleQuickUpdateGlobalDevice(
device: GlobalDeviceDto,
key: "name" | "displayName" | "category",
key: "name" | "displayName" | "category" | "phaseCount",
value: string
) {
const payload: CreateGlobalDeviceInput = {
@@ -153,8 +203,12 @@ export default function ProjectsPage() {
quantity: device.quantity,
installedPowerPerUnitKw: device.installedPowerPerUnitKw,
demandFactor: device.demandFactor,
voltageV: device.voltageV ?? undefined,
phaseCount: device.phaseCount ?? undefined,
phaseCount:
key === "phaseCount"
? value === "3"
? 3
: 1
: device.phaseCount ?? 1,
powerFactor: device.powerFactor ?? undefined,
note: device.note ?? undefined,
};
@@ -179,7 +233,7 @@ export default function ProjectsPage() {
<div className="row g-4">
<section className="col-12 col-lg-4">
<div className="card shadow-sm">
<div className="card shadow-sm mb-4">
<div className="card-header">Neues Projekt</div>
<div className="card-body">
<form className="vstack gap-3" onSubmit={handleCreateProject}>
@@ -198,6 +252,60 @@ export default function ProjectsPage() {
</form>
</div>
</div>
<div className="card shadow-sm">
<div className="card-header">Projekt importieren</div>
<div className="card-body">
<p className="text-secondary small">
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
mit neuen internen IDs angelegt.
</p>
<label className="form-label" htmlFor="project-overview-import">
Projektdatei auswählen
</label>
<input
accept=".json,application/json"
className="form-control"
disabled={isSaving}
id="project-overview-import"
key={projectImportInputKey}
onChange={(event) =>
void handleProjectImportFile(event.target.files?.[0])
}
type="file"
/>
{projectImportFilename ? (
<div className="form-text">
{projectImportFilename} ist bereit.
</div>
) : null}
{projectImportFileError ? (
<div className="text-danger small mt-1">
{projectImportFileError}
</div>
) : null}
<button
className="btn btn-outline-primary mt-3 w-100"
disabled={isSaving || projectImport === null}
onClick={() => void handleImportProject()}
type="button"
>
Als neues Projekt importieren
</button>
{importedProject ? (
<div className="alert alert-success mt-3 mb-0">
<div className="small mb-2">
{importedProject.name} wurde angelegt.
</div>
<Link
className="btn btn-sm btn-success"
href={`/projects/${importedProject.projectId}`}
>
Importiertes Projekt öffnen
</Link>
</div>
) : null}
</div>
</div>
</section>
<section className="col-12 col-lg-8">
@@ -313,8 +421,8 @@ export default function ProjectsPage() {
setGlobalDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
}
>
<option value="1">1-ph</option>
<option value="3">3-ph</option>
<option value="1">1-phasig</option>
<option value="3">3-phasig</option>
</select>
</div>
<div className="col-6 col-md-2">
@@ -334,6 +442,7 @@ export default function ProjectsPage() {
<th>Anzahl</th>
<th>Leistung je Stück [kW]</th>
<th>GZF</th>
<th>Phasenart</th>
<th>Aktion</th>
</tr>
</thead>
@@ -376,6 +485,23 @@ export default function ProjectsPage() {
<td>{device.quantity}</td>
<td>{device.installedPowerPerUnitKw}</td>
<td>{device.demandFactor}</td>
<td>
<select
className="form-select form-select-sm"
aria-label={`Phasenart für ${device.displayName}`}
value={String(device.phaseCount ?? 1)}
onChange={(event) =>
void handleQuickUpdateGlobalDevice(
device,
"phaseCount",
event.target.value
)
}
>
<option value="1">1-phasig</option>
<option value="3">3-phasig</option>
</select>
</td>
<td>
<button
className="btn btn-sm btn-outline-danger"
@@ -389,7 +515,7 @@ export default function ProjectsPage() {
))}
{!globalDevices.length ? (
<tr>
<td colSpan={7} className="text-center text-secondary py-4">
<td colSpan={8} className="text-center text-secondary py-4">
Noch keine globalen Geräte vorhanden.
</td>
</tr>
+5 -9
View File
@@ -1,13 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { createDatabaseContext } from "./database-context.js";
const dataDir = path.resolve("data");
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const defaultDatabaseContext = createDatabaseContext(
path.resolve("data", "leistungsbilanz.db")
);
const sqlite = new Database(path.resolve(dataDir, "leistungsbilanz.db"));
export const db = drizzle(sqlite);
export const db = defaultDatabaseContext.db;
+78
View File
@@ -0,0 +1,78 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
export interface DatabaseVerification {
integrityCheck: "ok";
foreignKeyViolations: number;
}
function resolveDatabasePath(filename: string): string {
return path.resolve(filename);
}
export function verifyDatabaseFile(filename: string): DatabaseVerification {
const databasePath = resolveDatabasePath(filename);
const sqlite = new Database(databasePath, {
readonly: true,
fileMustExist: true,
});
try {
const integrityCheck = sqlite.pragma("integrity_check", {
simple: true,
});
if (integrityCheck !== "ok") {
throw new Error(
`SQLite integrity check failed for ${databasePath}: ${String(integrityCheck)}`
);
}
const foreignKeyCheck = sqlite.pragma("foreign_key_check") as unknown[];
const foreignKeyViolations = foreignKeyCheck.length;
if (foreignKeyViolations > 0) {
throw new Error(
`SQLite foreign-key check failed for ${databasePath}: ${foreignKeyViolations} violation(s)`
);
}
return {
integrityCheck: "ok",
foreignKeyViolations,
};
} finally {
sqlite.close();
}
}
export async function createVerifiedDatabaseBackup(
sourceFilename: string,
destinationFilename: string
): Promise<DatabaseVerification> {
const sourcePath = resolveDatabasePath(sourceFilename);
const destinationPath = resolveDatabasePath(destinationFilename);
if (sourcePath === destinationPath) {
throw new Error("Backup source and destination must be different files.");
}
if (!fs.existsSync(sourcePath)) {
throw new Error(`Database file not found: ${sourcePath}`);
}
if (fs.existsSync(destinationPath)) {
throw new Error(`Backup destination already exists: ${destinationPath}`);
}
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
const source = new Database(sourcePath, {
readonly: true,
fileMustExist: true,
});
try {
await source.backup(destinationPath);
} finally {
source.close();
}
return verifyDatabaseFile(destinationPath);
}
+33
View File
@@ -0,0 +1,33 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import {
drizzle,
type BetterSQLite3Database,
} from "drizzle-orm/better-sqlite3";
export interface DatabaseContext {
db: BetterSQLite3Database;
sqlite: Database.Database;
close: () => void;
}
export type AppDatabase = DatabaseContext["db"];
export function createDatabaseContext(filename: string): DatabaseContext {
if (filename !== ":memory:") {
const parentDirectory = path.dirname(path.resolve(filename));
if (!fs.existsSync(parentDirectory)) {
fs.mkdirSync(parentDirectory, { recursive: true });
}
}
const sqlite = new Database(filename);
sqlite.pragma("foreign_keys = ON");
const database = drizzle(sqlite);
return {
db: database,
sqlite,
close: () => sqlite.close(),
};
}
@@ -0,0 +1,9 @@
ALTER TABLE `project_devices` DROP COLUMN `installed_power_per_unit_kw`;
--> statement-breakpoint
ALTER TABLE `project_devices` DROP COLUMN `demand_factor`;
--> statement-breakpoint
ALTER TABLE `project_devices` DROP COLUMN `phase_count`;
--> statement-breakpoint
ALTER TABLE `project_devices` DROP COLUMN `power_factor`;
--> statement-breakpoint
ALTER TABLE `project_devices` DROP COLUMN `note`;
@@ -0,0 +1,24 @@
CREATE TABLE `project_change_sets` (
`id` text PRIMARY KEY NOT NULL,
`project_revision_id` text NOT NULL,
`command_type` text NOT NULL,
`payload_schema_version` integer NOT NULL,
`forward_payload_json` text NOT NULL,
`inverse_payload_json` text NOT NULL,
FOREIGN KEY (`project_revision_id`) REFERENCES `project_revisions`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_change_sets_revision_unique` ON `project_change_sets` (`project_revision_id`);--> statement-breakpoint
CREATE TABLE `project_revisions` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`revision_number` integer NOT NULL,
`created_at_iso` text NOT NULL,
`actor_id` text,
`source` text NOT NULL,
`description` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_revisions_project_number_unique` ON `project_revisions` (`project_id`,`revision_number`);--> statement-breakpoint
ALTER TABLE `projects` ADD `current_revision` integer DEFAULT 0 NOT NULL;
@@ -0,0 +1,11 @@
CREATE TABLE `project_history_stack_entries` (
`change_set_id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`stack` text NOT NULL,
`position` integer NOT NULL,
FOREIGN KEY (`change_set_id`) REFERENCES `project_change_sets`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "project_history_stack_entries_stack_check" CHECK("project_history_stack_entries"."stack" in ('undo', 'redo'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_history_stack_entries_position_unique` ON `project_history_stack_entries` (`project_id`,`stack`,`position`);
@@ -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`);
@@ -0,0 +1,4 @@
ALTER TABLE `projects` ADD `internal_project_number` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `external_project_number` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `building_owner` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `description` text;
@@ -0,0 +1,2 @@
ALTER TABLE `distribution_boards` ADD `floor_id` text REFERENCES floors(id);--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `supply_type` text;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `projects` ADD `enabled_distribution_board_supply_types` text DEFAULT '["AV","SV","EV","USV","MSR","SiBe"]' NOT NULL;
@@ -0,0 +1,48 @@
UPDATE `global_devices`
SET `voltage_v` = NULL;
--> statement-breakpoint
UPDATE `project_devices` AS `pd`
SET `voltage_v` = CASE
WHEN `pd`.`phase_type` = 'three_phase' THEN `p`.`three_phase_voltage_v`
ELSE `p`.`single_phase_voltage_v`
END
FROM `projects` AS `p`
WHERE `p`.`id` = `pd`.`project_id`;
--> statement-breakpoint
WITH `derived_circuit_voltages` AS (
SELECT
`c`.`id` AS `circuit_id`,
CASE
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
WHEN EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `three_phase_row`
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
AND `three_phase_row`.`phase_type` = 'three_phase'
) AND NOT EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `other_row`
WHERE `other_row`.`circuit_id` = `c`.`id`
AND `other_row`.`phase_type` = 'single_phase'
) THEN `p`.`three_phase_voltage_v`
ELSE `p`.`single_phase_voltage_v`
END AS `derived_voltage`
FROM `circuits` AS `c`
INNER JOIN `circuit_lists` AS `cl`
ON `cl`.`id` = `c`.`circuit_list_id`
INNER JOIN `projects` AS `p`
ON `p`.`id` = `cl`.`project_id`
INNER JOIN `circuit_sections` AS `cs`
ON `cs`.`id` = `c`.`section_id`
)
UPDATE `circuits`
SET `voltage` = (
SELECT `derived_voltage`
FROM `derived_circuit_voltages`
WHERE `circuit_id` = `circuits`.`id`
)
WHERE `id` IN (
SELECT `circuit_id`
FROM `derived_circuit_voltages`
);
@@ -0,0 +1,85 @@
UPDATE `project_devices`
SET `phase_type` = CASE
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN ('1ph', '1phase', '1phasig', 'einphasig', 'singlephase')
THEN 'single_phase'
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN ('3ph', '3phase', '3phasig', 'dreiphasig', 'threephase')
THEN 'three_phase'
ELSE `phase_type`
END
WHERE LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN (
'1ph',
'1phase',
'1phasig',
'einphasig',
'singlephase',
'3ph',
'3phase',
'3phasig',
'dreiphasig',
'threephase'
);
--> statement-breakpoint
UPDATE `circuit_device_rows`
SET `phase_type` = CASE
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN ('1ph', '1phase', '1phasig', 'einphasig', 'singlephase')
THEN 'single_phase'
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN ('3ph', '3phase', '3phasig', 'dreiphasig', 'threephase')
THEN 'three_phase'
ELSE `phase_type`
END
WHERE LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN (
'1ph',
'1phase',
'1phasig',
'einphasig',
'singlephase',
'3ph',
'3phase',
'3phasig',
'dreiphasig',
'threephase'
);
--> statement-breakpoint
WITH `derived_circuit_voltages` AS (
SELECT
`c`.`id` AS `circuit_id`,
CASE
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
WHEN EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `three_phase_row`
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
AND `three_phase_row`.`phase_type` = 'three_phase'
) AND NOT EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `single_phase_row`
WHERE `single_phase_row`.`circuit_id` = `c`.`id`
AND `single_phase_row`.`phase_type` = 'single_phase'
) THEN `p`.`three_phase_voltage_v`
ELSE `p`.`single_phase_voltage_v`
END AS `derived_voltage`
FROM `circuits` AS `c`
INNER JOIN `circuit_lists` AS `cl`
ON `cl`.`id` = `c`.`circuit_list_id`
INNER JOIN `projects` AS `p`
ON `p`.`id` = `cl`.`project_id`
INNER JOIN `circuit_sections` AS `cs`
ON `cs`.`id` = `c`.`section_id`
)
UPDATE `circuits`
SET `voltage` = (
SELECT `derived_voltage`
FROM `derived_circuit_voltages`
WHERE `circuit_id` = `circuits`.`id`
)
WHERE `id` IN (
SELECT `circuit_id`
FROM `derived_circuit_voltages`
);
@@ -0,0 +1,75 @@
UPDATE `circuit_device_rows`
SET `phase_type` = COALESCE(
(
SELECT `pd`.`phase_type`
FROM `project_devices` AS `pd`
WHERE `pd`.`id` = `circuit_device_rows`.`linked_project_device_id`
AND `pd`.`phase_type` IN ('single_phase', 'three_phase')
),
(
SELECT CASE
WHEN `cs`.`key` = 'three_phase' THEN 'three_phase'
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN 'single_phase'
ELSE NULL
END
FROM `circuits` AS `c`
INNER JOIN `circuit_sections` AS `cs`
ON `cs`.`id` = `c`.`section_id`
WHERE `c`.`id` = `circuit_device_rows`.`circuit_id`
)
)
WHERE `phase_type` IS NULL
AND (
EXISTS (
SELECT 1
FROM `project_devices` AS `pd`
WHERE `pd`.`id` = `circuit_device_rows`.`linked_project_device_id`
AND `pd`.`phase_type` IN ('single_phase', 'three_phase')
)
OR EXISTS (
SELECT 1
FROM `circuits` AS `c`
INNER JOIN `circuit_sections` AS `cs`
ON `cs`.`id` = `c`.`section_id`
WHERE `c`.`id` = `circuit_device_rows`.`circuit_id`
AND `cs`.`key` IN ('lighting', 'single_phase', 'three_phase')
)
);
--> statement-breakpoint
WITH `derived_circuit_voltages` AS (
SELECT
`c`.`id` AS `circuit_id`,
CASE
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
WHEN EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `three_phase_row`
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
AND `three_phase_row`.`phase_type` = 'three_phase'
) AND NOT EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `single_phase_row`
WHERE `single_phase_row`.`circuit_id` = `c`.`id`
AND `single_phase_row`.`phase_type` = 'single_phase'
) THEN `p`.`three_phase_voltage_v`
ELSE `p`.`single_phase_voltage_v`
END AS `derived_voltage`
FROM `circuits` AS `c`
INNER JOIN `circuit_lists` AS `cl`
ON `cl`.`id` = `c`.`circuit_list_id`
INNER JOIN `projects` AS `p`
ON `p`.`id` = `cl`.`project_id`
INNER JOIN `circuit_sections` AS `cs`
ON `cs`.`id` = `c`.`section_id`
)
UPDATE `circuits`
SET `voltage` = (
SELECT `derived_voltage`
FROM `derived_circuit_voltages`
WHERE `circuit_id` = `circuits`.`id`
)
WHERE `id` IN (
SELECT `circuit_id`
FROM `derived_circuit_voltages`
);
@@ -0,0 +1,7 @@
UPDATE `circuit_device_rows`
SET `phase_type` = 'single_phase'
WHERE `phase_type` IS NULL;
--> statement-breakpoint
UPDATE `global_devices`
SET `phase_count` = 1
WHERE `phase_count` IS NULL OR `phase_count` NOT IN (1, 3);
@@ -0,0 +1 @@
ALTER TABLE `distribution_boards` ADD `simultaneity_factor` real DEFAULT 1 NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+91
View File
@@ -78,6 +78,97 @@
"when": 1784746800000,
"tag": "0010_circuit_control_requirement",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1784832541202,
"tag": "0011_project_device_canonical_fields",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1784833957929,
"tag": "0012_project_revision_foundation",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"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
},
{
"idx": 16,
"version": "6",
"when": 1785254079435,
"tag": "0016_dashing_darkstar",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1785307189539,
"tag": "0017_vengeful_romulus",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1785308458789,
"tag": "0018_fancy_argent",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1785310907349,
"tag": "0019_normalize_project_voltages",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1785314248007,
"tag": "0020_normalize_phase_types",
"breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1785314558555,
"tag": "0021_fill_missing_phase_types",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1785314626476,
"tag": "0022_default_remaining_phase_types",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1785343645615,
"tag": "0023_cheerful_night_thrasher",
"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;
}
@@ -0,0 +1,531 @@
import { and, eq, inArray } from "drizzle-orm";
import {
assertCircuitDeviceRowMoveProjectCommand,
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand,
circuitDeviceRowMoveCommandType,
circuitDeviceRowMoveWithNewCircuitCommandType,
createCircuitDeviceRowMoveProjectCommand,
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
type CircuitDeviceRowMoveAssignment,
type CircuitDeviceRowMoveHistoryProjectCommand,
} from "../../domain/models/circuit-device-row-move-project-command.model.js";
import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js";
import type {
CircuitDeviceRowMoveProjectCommandStore,
ExecuteCircuitDeviceRowMoveCommandInput,
} from "../../domain/ports/circuit-device-row-move-project-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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
interface PersistedMoveRow {
id: string;
circuitId: string;
sortOrder: number;
phaseType: string | null;
}
export class CircuitDeviceRowMoveProjectCommandRepository
implements CircuitDeviceRowMoveProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowMoveCommandInput) {
this.assertSupportedCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(tx, input.projectId, input.command)
);
}
private assertSupportedCommand(
command: CircuitDeviceRowMoveHistoryProjectCommand
) {
if (command.type === circuitDeviceRowMoveCommandType) {
assertCircuitDeviceRowMoveProjectCommand(command);
return;
}
if (
command.type ===
circuitDeviceRowMoveWithNewCircuitCommandType
) {
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
command
);
return;
}
throw new Error("Unsupported circuit device-row move command.");
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: CircuitDeviceRowMoveHistoryProjectCommand
): CircuitDeviceRowMoveHistoryProjectCommand {
if (command.type === circuitDeviceRowMoveCommandType) {
const rowsById = this.loadExpectedRows(
database,
command.payload.moves
);
const circuitIds = this.collectCircuitIds(
command.payload.moves
);
this.assertExistingCircuitsInOneList(
database,
projectId,
circuitIds
);
const inverse = createCircuitDeviceRowMoveProjectCommand(
this.reverseMoves(command.payload.moves, rowsById)
);
this.applyMoves(database, command.payload.moves);
this.updateReserveStates(database, circuitIds);
this.updateVoltages(database, projectId, circuitIds);
return inverse;
}
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
command
);
const { targetCircuit, targetCircuitAction, moves } =
command.payload;
const rowsById = this.loadExpectedRows(database, moves);
const appliedTargetCircuit = {
...targetCircuit,
voltage: resolveCircuitVoltage(
database,
projectId,
targetCircuit.sectionId,
moves.map((move) => rowsById.get(move.rowId)?.phaseType)
),
};
this.assertCircuitLocation(database, projectId, appliedTargetCircuit);
if (targetCircuitAction === "create") {
const sourceCircuitIds = [
...new Set(moves.map((move) => move.expectedCircuitId)),
];
this.assertExistingCircuitsInOneList(
database,
projectId,
sourceCircuitIds,
targetCircuit.circuitListId
);
this.assertTargetCircuitAvailable(database, appliedTargetCircuit);
this.insertTargetCircuit(database, appliedTargetCircuit);
const inverse =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"delete",
appliedTargetCircuit,
this.reverseMoves(moves, rowsById)
);
this.applyMoves(database, moves);
this.updateReserveStates(database, [
...sourceCircuitIds,
targetCircuit.id,
]);
this.updateVoltages(database, projectId, [
...sourceCircuitIds,
targetCircuit.id,
]);
return inverse;
}
this.assertTargetCircuitUnchanged(
database,
appliedTargetCircuit,
moves.map((move) => move.rowId)
);
const destinationCircuitIds = [
...new Set(moves.map((move) => move.targetCircuitId)),
];
this.assertExistingCircuitsInOneList(
database,
projectId,
destinationCircuitIds,
targetCircuit.circuitListId
);
const inverse =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
appliedTargetCircuit,
this.reverseMoves(moves, rowsById)
);
this.applyMoves(database, moves);
this.updateReserveStates(database, [
targetCircuit.id,
...destinationCircuitIds,
]);
this.updateVoltages(
database,
projectId,
destinationCircuitIds
);
const deleted = database
.delete(circuits)
.where(
and(
eq(circuits.id, targetCircuit.id),
eq(
circuits.circuitListId,
targetCircuit.circuitListId
),
eq(circuits.isReserve, 1)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error(
"Created target circuit changed before deletion."
);
}
return inverse;
}
private updateVoltages(
database: AppDatabase,
projectId: string,
circuitIds: string[]
) {
for (const circuitId of new Set(circuitIds)) {
updateDerivedCircuitVoltage(database, projectId, circuitId);
}
}
private loadExpectedRows(
database: AppDatabase,
moves: CircuitDeviceRowMoveAssignment[]
) {
const rowIds = moves.map((move) => move.rowId);
const persistedRows = database
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
sortOrder: circuitDeviceRows.sortOrder,
phaseType: circuitDeviceRows.phaseType,
})
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (persistedRows.length !== rowIds.length) {
throw new Error(
"One or more circuit device rows do not exist."
);
}
const rowsById = new Map(
persistedRows.map((row) => [row.id, row])
);
for (const move of moves) {
const row = rowsById.get(move.rowId);
if (
!row ||
row.circuitId !== move.expectedCircuitId ||
row.sortOrder !== move.expectedSortOrder
) {
throw new Error(
"Circuit device row changed before move execution."
);
}
}
return rowsById;
}
private collectCircuitIds(
moves: CircuitDeviceRowMoveAssignment[]
) {
return [
...new Set(
moves.flatMap((move) => [
move.expectedCircuitId,
move.targetCircuitId,
])
),
];
}
private assertExistingCircuitsInOneList(
database: AppDatabase,
projectId: string,
circuitIds: string[],
expectedCircuitListId?: string
) {
const participatingCircuits = database
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
projectId: circuitLists.projectId,
})
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(inArray(circuits.id, circuitIds))
.all();
if (participatingCircuits.length !== circuitIds.length) {
throw new Error(
"One or more move circuits do not exist."
);
}
if (
participatingCircuits.some(
(circuit) => circuit.projectId !== projectId
)
) {
throw new Error("Move circuit does not belong to project.");
}
const circuitListIds = new Set(
participatingCircuits.map(
(circuit) => circuit.circuitListId
)
);
if (
circuitListIds.size !== 1 ||
(expectedCircuitListId !== undefined &&
!circuitListIds.has(expectedCircuitListId))
) {
throw new Error(
"All moved rows and targets must belong to one circuit list."
);
}
}
private assertCircuitLocation(
database: AppDatabase,
projectId: string,
snapshot: CircuitSnapshot
) {
const list = database
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, snapshot.circuitListId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!list) {
throw new Error("Circuit list does not belong to project.");
}
const section = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(
and(
eq(circuitSections.id, snapshot.sectionId),
eq(
circuitSections.circuitListId,
snapshot.circuitListId
)
)
)
.get();
if (!section) {
throw new Error("Section does not belong to circuit list.");
}
}
private assertTargetCircuitAvailable(
database: AppDatabase,
snapshot: CircuitSnapshot
) {
const existingCircuit = database
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, snapshot.id))
.get();
if (existingCircuit) {
throw new Error("Move target circuit id already exists.");
}
const duplicateIdentifier = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, snapshot.circuitListId),
eq(
circuits.equipmentIdentifier,
snapshot.equipmentIdentifier
)
)
)
.get();
if (duplicateIdentifier) {
throw new Error(
"Duplicate equipmentIdentifier in circuit list."
);
}
}
private insertTargetCircuit(
database: AppDatabase,
snapshot: CircuitSnapshot
) {
database
.insert(circuits)
.values({
id: snapshot.id,
circuitListId: snapshot.circuitListId,
sectionId: snapshot.sectionId,
equipmentIdentifier: snapshot.equipmentIdentifier,
displayName: snapshot.displayName,
sortOrder: snapshot.sortOrder,
protectionType: snapshot.protectionType,
protectionRatedCurrent: snapshot.protectionRatedCurrent,
protectionCharacteristic: snapshot.protectionCharacteristic,
cableType: snapshot.cableType,
cableCrossSection: snapshot.cableCrossSection,
cableLength: snapshot.cableLength,
rcdAssignment: snapshot.rcdAssignment,
terminalDesignation: snapshot.terminalDesignation,
voltage: snapshot.voltage,
controlRequirement: snapshot.controlRequirement,
status: snapshot.status,
isReserve: 1,
remark: snapshot.remark,
})
.run();
}
private assertTargetCircuitUnchanged(
database: AppDatabase,
snapshot: CircuitSnapshot,
expectedRowIds: string[]
) {
const current = database
.select()
.from(circuits)
.where(eq(circuits.id, snapshot.id))
.get();
if (
!current ||
current.circuitListId !== snapshot.circuitListId ||
current.sectionId !== snapshot.sectionId ||
current.equipmentIdentifier !==
snapshot.equipmentIdentifier ||
current.displayName !== snapshot.displayName ||
current.sortOrder !== snapshot.sortOrder ||
current.protectionType !== snapshot.protectionType ||
current.protectionRatedCurrent !==
snapshot.protectionRatedCurrent ||
current.protectionCharacteristic !==
snapshot.protectionCharacteristic ||
current.cableType !== snapshot.cableType ||
current.cableCrossSection !== snapshot.cableCrossSection ||
current.cableLength !== snapshot.cableLength ||
current.rcdAssignment !== snapshot.rcdAssignment ||
current.terminalDesignation !==
snapshot.terminalDesignation ||
current.voltage !== snapshot.voltage ||
current.controlRequirement !==
snapshot.controlRequirement ||
current.status !== snapshot.status ||
current.remark !== snapshot.remark ||
Boolean(current.isReserve)
) {
throw new Error(
"Created target circuit changed before command execution."
);
}
const currentRowIds = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, snapshot.id))
.all()
.map((row) => row.id);
const expectedRowIdSet = new Set(expectedRowIds);
if (
currentRowIds.length !== expectedRowIds.length ||
currentRowIds.some((id) => !expectedRowIdSet.has(id))
) {
throw new Error(
"Created target circuit rows changed before command execution."
);
}
}
private reverseMoves(
moves: CircuitDeviceRowMoveAssignment[],
rowsById: Map<string, PersistedMoveRow>
) {
return moves.map((move) => {
const current = rowsById.get(move.rowId)!;
return {
rowId: move.rowId,
expectedCircuitId: move.targetCircuitId,
expectedSortOrder: move.targetSortOrder,
targetCircuitId: current.circuitId,
targetSortOrder: current.sortOrder,
};
});
}
private applyMoves(
database: AppDatabase,
moves: CircuitDeviceRowMoveAssignment[]
) {
for (const move of moves) {
const result = database
.update(circuitDeviceRows)
.set({
circuitId: move.targetCircuitId,
sortOrder: move.targetSortOrder,
})
.where(
and(
eq(circuitDeviceRows.id, move.rowId),
eq(
circuitDeviceRows.circuitId,
move.expectedCircuitId
),
eq(
circuitDeviceRows.sortOrder,
move.expectedSortOrder
)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"Circuit device row changed during move execution."
);
}
}
}
private updateReserveStates(
database: AppDatabase,
circuitIds: string[]
) {
for (const circuitId of new Set(circuitIds)) {
const remainingRow = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId))
.limit(1)
.get();
const updated = database
.update(circuits)
.set({ isReserve: remainingRow ? 0 : 1 })
.where(eq(circuits.id, circuitId))
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during device-row move execution."
);
}
}
}
}
@@ -0,0 +1,200 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitDeviceRowUpdateProjectCommand,
createCircuitDeviceRowUpdateProjectCommand,
type CircuitDeviceRowUpdateField,
type CircuitDeviceRowUpdatePatch,
type CircuitDeviceRowUpdateValues,
} from "../../domain/models/circuit-device-row-project-command.model.js";
import type {
CircuitDeviceRowProjectCommandStore,
ExecuteCircuitDeviceRowUpdateCommandInput,
} from "../../domain/ports/circuit-device-row-project-command.store.js";
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
import {
toCircuitDeviceRowPatchValues,
type CircuitDeviceRowPatchInput,
} from "./circuit-device-row.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
export class CircuitDeviceRowProjectCommandRepository
implements CircuitDeviceRowProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
assertCircuitDeviceRowUpdateProjectCommand(input.command);
return executeProjectCommandTransactionWithAppliedForward(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitDeviceRowUpdateCommandInput
) {
const current = tx
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, input.command.payload.rowId))
.get();
if (!current) {
throw new Error("Invalid device row id.");
}
const circuit = tx
.select({ circuitListId: circuits.circuitListId })
.from(circuits)
.where(eq(circuits.id, current.circuitId))
.get();
const owningList = circuit
? tx
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, circuit.circuitListId),
eq(circuitLists.projectId, input.projectId)
)
)
.get()
: null;
if (!owningList) {
throw new Error("Circuit device row does not belong to project.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as CircuitDeviceRowPatchInput;
if (
input.source === "user" &&
patch.phaseType !== undefined &&
!isElectricalPhaseType(patch.phaseType)
) {
throw new Error("Device-row phase type is invalid.");
}
this.assertLinkedProjectDevice(tx, input.projectId, patch);
this.assertRoom(tx, input.projectId, patch);
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current,
patch
);
if (
overriddenFields !== undefined &&
patch.overriddenFields === undefined
) {
patch.overriddenFields = overriddenFields;
}
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
current.id,
patch as CircuitDeviceRowUpdatePatch
);
const inversePatch = Object.fromEntries(
appliedForward.payload.changes.map((change) => [
change.field,
getCircuitDeviceRowFieldValue(current, change.field),
])
) as CircuitDeviceRowUpdatePatch;
const inverse = createCircuitDeviceRowUpdateProjectCommand(
current.id,
inversePatch
);
const update = tx
.update(circuitDeviceRows)
.set(toCircuitDeviceRowPatchValues(patch))
.where(eq(circuitDeviceRows.id, current.id))
.run();
if (update.changes !== 1) {
throw new Error("Circuit device row changed before command execution.");
}
if (patch.phaseType !== undefined) {
updateDerivedCircuitVoltage(
tx,
input.projectId,
current.circuitId
);
}
return {
forward: appliedForward,
inverse,
};
}
private assertLinkedProjectDevice(
database: AppDatabase,
projectId: string,
patch: CircuitDeviceRowPatchInput
) {
if (
!Object.prototype.hasOwnProperty.call(patch, "linkedProjectDeviceId") ||
patch.linkedProjectDeviceId === null ||
patch.linkedProjectDeviceId === undefined
) {
return;
}
const device = database
.select({ id: projectDevices.id })
.from(projectDevices)
.where(
and(
eq(projectDevices.id, patch.linkedProjectDeviceId),
eq(projectDevices.projectId, projectId)
)
)
.get();
if (!device) {
throw new Error("Invalid linked project device id.");
}
}
private assertRoom(
database: AppDatabase,
projectId: string,
patch: CircuitDeviceRowPatchInput
) {
if (
!Object.prototype.hasOwnProperty.call(patch, "roomId") ||
patch.roomId === null ||
patch.roomId === undefined
) {
return;
}
const room = database
.select({ id: rooms.id })
.from(rooms)
.where(and(eq(rooms.id, patch.roomId), eq(rooms.projectId, projectId)))
.get();
if (!room) {
throw new Error("Invalid room id.");
}
}
}
function getCircuitDeviceRowFieldValue<
TField extends CircuitDeviceRowUpdateField,
>(
row: CircuitDeviceRow,
field: TField
): CircuitDeviceRowUpdateValues[TField] {
return row[field] as unknown as CircuitDeviceRowUpdateValues[TField];
}
@@ -0,0 +1,196 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitDeviceRowDeleteProjectCommand,
assertCircuitDeviceRowInsertProjectCommand,
circuitDeviceRowDeleteCommandType,
circuitDeviceRowInsertCommandType,
createCircuitDeviceRowDeleteProjectCommand,
createCircuitDeviceRowInsertProjectCommand,
type CircuitDeviceRowSnapshot,
type CircuitDeviceRowStructureProjectCommand,
} from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type {
CircuitDeviceRowStructureProjectCommandStore,
ExecuteCircuitDeviceRowStructureCommandInput,
} from "../../domain/ports/circuit-device-row-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(
tx,
input.projectId,
input.source,
input.command
)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
command: CircuitDeviceRowStructureProjectCommand
): CircuitDeviceRowStructureProjectCommand {
if (command.type === circuitDeviceRowInsertCommandType) {
assertCircuitDeviceRowInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.row
);
}
if (command.type === circuitDeviceRowDeleteCommandType) {
assertCircuitDeviceRowDeleteProjectCommand(command);
return this.delete(
database,
projectId,
command.payload.rowId,
command.payload.expectedCircuitId
);
}
throw new Error("Unsupported circuit device-row structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
row: CircuitDeviceRowSnapshot
) {
if (source === "user" && row.legacyConsumerId !== null) {
throw new Error(
"User commands cannot assign legacy consumer ids."
);
}
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid.");
}
this.assertCircuitInProject(database, projectId, row.circuitId);
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
const existing = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, row.id))
.get();
if (existing) {
throw new Error("Circuit device-row id already exists.");
}
database.insert(circuitDeviceRows).values(row).run();
const circuitUpdate = database
.update(circuits)
.set({ isReserve: 0 })
.where(eq(circuits.id, row.circuitId))
.run();
if (circuitUpdate.changes !== 1) {
throw new Error("Circuit changed before device-row insertion.");
}
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
return createCircuitDeviceRowDeleteProjectCommand(
row.id,
row.circuitId
);
}
private delete(
database: AppDatabase,
projectId: string,
rowId: string,
expectedCircuitId: string
) {
const row = database
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.get();
if (!row || row.circuitId !== expectedCircuitId) {
throw new Error(
"Circuit device row changed before command execution."
);
}
this.assertCircuitInProject(database, projectId, row.circuitId);
const result = database
.delete(circuitDeviceRows)
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.circuitId, expectedCircuitId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("Circuit device row could not be deleted.");
}
const remainingRow = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
.limit(1)
.get();
const circuitUpdate = database
.update(circuits)
.set({ isReserve: remainingRow ? 0 : 1 })
.where(eq(circuits.id, expectedCircuitId))
.run();
if (circuitUpdate.changes !== 1) {
throw new Error("Circuit changed before device-row deletion.");
}
updateDerivedCircuitVoltage(
database,
projectId,
expectedCircuitId
);
return createCircuitDeviceRowInsertProjectCommand(
toCircuitDeviceRowSnapshot(row)
);
}
private assertCircuitInProject(
database: AppDatabase,
projectId: string,
circuitId: string
) {
const circuit = database
.select({ id: circuits.id })
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(
and(
eq(circuits.id, circuitId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!circuit) {
throw new Error("Circuit does not belong to project.");
}
}
}
@@ -0,0 +1,71 @@
import { and, eq } from "drizzle-orm";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
export function assertCircuitDeviceRowReferencesInProject(
database: AppDatabase,
projectId: string,
row: CircuitDeviceRowSnapshot
) {
if (row.linkedProjectDeviceId !== null) {
const device = database
.select({ id: projectDevices.id })
.from(projectDevices)
.where(
and(
eq(projectDevices.id, row.linkedProjectDeviceId),
eq(projectDevices.projectId, projectId)
)
)
.get();
if (!device) {
throw new Error("Invalid linked project device id.");
}
}
if (row.roomId !== null) {
const room = database
.select({ id: rooms.id })
.from(rooms)
.where(
and(
eq(rooms.id, row.roomId),
eq(rooms.projectId, projectId)
)
)
.get();
if (!room) {
throw new Error("Invalid room id.");
}
}
}
export function toCircuitDeviceRowSnapshot(
row: typeof circuitDeviceRows.$inferSelect
): CircuitDeviceRowSnapshot {
return {
id: row.id,
circuitId: row.circuitId,
linkedProjectDeviceId: row.linkedProjectDeviceId,
legacyConsumerId: row.legacyConsumerId,
sortOrder: row.sortOrder,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType,
connectionKind: row.connectionKind,
costGroup: row.costGroup,
category: row.category,
level: row.level,
roomId: row.roomId,
roomNumberSnapshot: row.roomNumberSnapshot,
roomNameSnapshot: row.roomNameSnapshot,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark,
overriddenFields: row.overriddenFields,
};
}
@@ -0,0 +1,117 @@
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
export interface CircuitDeviceRowUpdateInput {
linkedProjectDeviceId?: string;
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 CircuitDeviceRowPatchInput {
linkedProjectDeviceId?: string | null;
sortOrder?: number;
name?: string;
displayName?: string;
phaseType?: string | null;
connectionKind?: string | null;
costGroup?: string | null;
category?: string | null;
level?: string | null;
roomId?: string | null;
roomNumberSnapshot?: string | null;
roomNameSnapshot?: string | null;
quantity?: number;
powerPerUnit?: number;
simultaneityFactor?: number;
cosPhi?: number | null;
remark?: string | null;
overriddenFields?: string | null;
}
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
circuitId: string;
linkedProjectDeviceId?: string;
legacyConsumerId?: string;
sortOrder: number;
}
export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) {
return {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType ?? null,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
level: input.level ?? null,
roomId: input.roomId ?? null,
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
roomNameSnapshot: input.roomNameSnapshot ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
overriddenFields: input.overriddenFields ?? null,
};
}
export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput) {
const values: Partial<typeof circuitDeviceRows.$inferInsert> = {};
const has = (field: keyof CircuitDeviceRowPatchInput) =>
Object.prototype.hasOwnProperty.call(input, field);
if (has("linkedProjectDeviceId")) {
values.linkedProjectDeviceId = input.linkedProjectDeviceId ?? null;
}
if (input.name !== undefined) values.name = input.name;
if (input.displayName !== undefined) values.displayName = input.displayName;
if (has("phaseType")) values.phaseType = input.phaseType ?? null;
if (has("connectionKind")) values.connectionKind = input.connectionKind ?? null;
if (has("costGroup")) values.costGroup = input.costGroup ?? null;
if (has("category")) values.category = input.category ?? null;
if (has("level")) values.level = input.level ?? null;
if (has("roomId")) values.roomId = input.roomId ?? null;
if (has("roomNumberSnapshot")) {
values.roomNumberSnapshot = input.roomNumberSnapshot ?? null;
}
if (has("roomNameSnapshot")) values.roomNameSnapshot = input.roomNameSnapshot ?? null;
if (input.quantity !== undefined) values.quantity = input.quantity;
if (input.powerPerUnit !== undefined) values.powerPerUnit = input.powerPerUnit;
if (input.simultaneityFactor !== undefined) {
values.simultaneityFactor = input.simultaneityFactor;
}
if (has("cosPhi")) values.cosPhi = input.cosPhi ?? null;
if (has("remark")) values.remark = input.remark ?? null;
if (has("overriddenFields")) values.overriddenFields = input.overriddenFields ?? null;
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
return values;
}
export function toCircuitDeviceRowCreateValues(
id: string,
input: CircuitDeviceRowCreateInput
) {
return {
id,
circuitId: input.circuitId,
legacyConsumerId: input.legacyConsumerId ?? null,
sortOrder: input.sortOrder,
...toCircuitDeviceRowUpdateValues(input),
};
}
@@ -1,81 +1,18 @@
import crypto from "node:crypto";
import { and, asc, eq, inArray, isNull } from "drizzle-orm";
import { db } from "../client.js";
import { and, asc, eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
export interface CircuitDeviceRowUpdateInput {
linkedProjectDeviceId?: string;
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 CircuitDeviceRowsBulkMoveInput {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
};
}
function toUpdateValues(input: CircuitDeviceRowUpdateInput) {
return {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType ?? null,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
level: input.level ?? null,
roomId: input.roomId ?? null,
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
roomNameSnapshot: input.roomNameSnapshot ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
overriddenFields: input.overriddenFields ?? null,
};
}
export class CircuitDeviceRowRepository {
async findById(rowId: string) {
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
return row ?? null;
}
async countByCircuit(circuitId: string) {
const rows = await db.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows).where(eq(circuitDeviceRows.circuitId, circuitId));
return rows.length;
}
constructor(private readonly database: AppDatabase) {}
async listByCircuitList(circuitIds: string[]) {
if (!circuitIds.length) {
return [];
}
return db
return this.database
.select()
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.circuitId, circuitIds))
@@ -83,7 +20,7 @@ export class CircuitDeviceRowRepository {
}
async listLinkedByProjectDevice(projectId: string, projectDeviceId: string) {
return db
return this.database
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
@@ -126,283 +63,4 @@ export class CircuitDeviceRowRepository {
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
}
async listLinkStatesByIds(projectId: string, rowIds: string[]) {
if (rowIds.length === 0) {
return [];
}
return db
.select({
id: circuitDeviceRows.id,
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
.where(and(eq(circuitLists.projectId, projectId), inArray(circuitDeviceRows.id, rowIds)));
}
async create(input: {
circuitId: string;
linkedProjectDeviceId?: string;
legacyConsumerId?: 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;
}) {
const id = crypto.randomUUID();
await db.insert(circuitDeviceRows).values({
id,
circuitId: input.circuitId,
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
legacyConsumerId: input.legacyConsumerId ?? null,
sortOrder: input.sortOrder,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType ?? null,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
level: input.level ?? null,
roomId: input.roomId ?? null,
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
roomNameSnapshot: input.roomNameSnapshot ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
overriddenFields: input.overriddenFields ?? null,
});
return id;
}
async update(
rowId: string,
input: CircuitDeviceRowUpdateInput
) {
await db
.update(circuitDeviceRows)
.set(toUpdateValues(input))
.where(eq(circuitDeviceRows.id, rowId));
}
updateLinkedRowsTransactional(
projectDeviceId: string,
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
) {
db.transaction((tx) => {
for (const change of changes) {
const result = tx
.update(circuitDeviceRows)
.set(toUpdateValues(change.input))
.where(
and(
eq(circuitDeviceRows.id, change.rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("A linked row changed before the operation could be completed.");
}
}
});
}
disconnectLinkedRowsTransactional(projectDeviceId: string, rowIds: string[]) {
db.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: null })
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("A linked row changed before the operation could be completed.");
}
}
});
}
reconnectRowsTransactional(projectDeviceId: string, rowIds: string[]) {
db.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: projectDeviceId })
.where(and(eq(circuitDeviceRows.id, rowId), isNull(circuitDeviceRows.linkedProjectDeviceId)))
.run();
if (result.changes !== 1) {
throw new Error("A disconnected row changed before the operation could be undone.");
}
}
});
}
async delete(rowId: string) {
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
}
async moveToCircuit(rowId: string, targetCircuitId: string, sortOrder: number) {
await db
.update(circuitDeviceRows)
.set({
circuitId: targetCircuitId,
sortOrder,
})
.where(eq(circuitDeviceRows.id, rowId));
}
moveRowsTransactional(input: CircuitDeviceRowsBulkMoveInput) {
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!;
// better-sqlite3 transactions must remain synchronous. Every row assignment,
// reserve-state update and optional target creation is committed or rolled back
// as one operation.
db.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,
};
}
}
+5 -48
View File
@@ -1,51 +1,17 @@
import { and, eq } from "drizzle-orm";
import { db } from "../client.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
export class CircuitListRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) {
const db = this.database;
return db.select().from(circuitLists).where(eq(circuitLists.projectId, projectId));
}
async createForDistributionBoard(input: {
projectId: string;
distributionBoardId: string;
name: string;
}) {
const entry = {
id: input.distributionBoardId,
projectId: input.projectId,
distributionBoardId: input.distributionBoardId,
name: input.name,
};
await db.insert(circuitLists).values(entry);
return entry;
}
async findByDistributionBoardId(projectId: string, distributionBoardId: string) {
const [row] = await db
.select()
.from(circuitLists)
.where(
and(
eq(circuitLists.projectId, projectId),
eq(circuitLists.distributionBoardId, distributionBoardId)
)
)
.limit(1);
return row ?? null;
}
async existsInProject(projectId: string, circuitListId: string) {
const [row] = await db
.select({ id: circuitLists.id })
.from(circuitLists)
.where(and(eq(circuitLists.projectId, projectId), eq(circuitLists.id, circuitListId)))
.limit(1);
return Boolean(row);
}
async findById(projectId: string, circuitListId: string) {
const db = this.database;
const [row] = await db
.select()
.from(circuitLists)
@@ -53,13 +19,4 @@ export class CircuitListRepository {
.limit(1);
return row ?? null;
}
async findByIdByListIdOnly(circuitListId: string) {
const [row] = await db
.select()
.from(circuitLists)
.where(eq(circuitLists.id, circuitListId))
.limit(1);
return row ?? null;
}
}
@@ -0,0 +1,194 @@
import { and, eq, ne } from "drizzle-orm";
import {
assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand,
type CircuitUpdateField,
type CircuitUpdatePatch,
type CircuitUpdateValues,
} from "../../domain/models/circuit-project-command.model.js";
import type {
CircuitProjectCommandStore,
ExecuteCircuitUpdateCommandInput,
} from "../../domain/ports/circuit-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 {
toCircuitPatchValues,
type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
type CircuitRow = typeof circuits.$inferSelect;
export class CircuitProjectCommandRepository
implements CircuitProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
assertCircuitUpdateProjectCommand(input.command);
return executeProjectCommandTransactionWithAppliedForward(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitUpdateCommandInput
) {
const current = tx
.select()
.from(circuits)
.where(eq(circuits.id, input.command.payload.circuitId))
.get();
if (!current) {
throw new Error("Invalid circuit id.");
}
const owningList = tx
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, current.circuitListId),
eq(circuitLists.projectId, input.projectId)
)
)
.get();
if (!owningList) {
throw new Error("Circuit does not belong to project.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as CircuitPatchPersistenceInput;
this.assertSectionInCircuitList(tx, current, patch.sectionId);
if (input.source === "user") {
if (
input.command.payload.changes.every(
(change) => change.field === "voltage"
)
) {
throw new Error("Circuit voltage is derived and cannot be edited.");
}
const devicePhaseTypes = tx
.select({ phaseType: circuitDeviceRows.phaseType })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, current.id))
.all()
.map((row) => row.phaseType);
const derivedVoltage = resolveCircuitVoltage(
tx,
input.projectId,
patch.sectionId ?? current.sectionId,
devicePhaseTypes
);
if (derivedVoltage === current.voltage) {
delete patch.voltage;
} else {
patch.voltage = derivedVoltage;
}
}
this.assertUniqueEquipmentIdentifier(
tx,
current,
patch.equipmentIdentifier
);
const appliedForward = createCircuitUpdateProjectCommand(
current.id,
patch as CircuitUpdatePatch
);
const inversePatch = Object.fromEntries(
appliedForward.payload.changes.map((change) => [
change.field,
getCircuitFieldValue(current, change.field),
])
) as CircuitUpdatePatch;
const inverse = createCircuitUpdateProjectCommand(
current.id,
inversePatch
);
const update = tx
.update(circuits)
.set(toCircuitPatchValues(patch))
.where(eq(circuits.id, current.id))
.run();
if (update.changes !== 1) {
throw new Error("Circuit changed before command execution.");
}
return { forward: appliedForward, inverse };
}
private assertSectionInCircuitList(
database: AppDatabase,
circuit: CircuitRow,
sectionId: string | undefined
) {
if (sectionId === undefined || sectionId === circuit.sectionId) {
return;
}
const section = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(
and(
eq(circuitSections.id, sectionId),
eq(circuitSections.circuitListId, circuit.circuitListId)
)
)
.get();
if (!section) {
throw new Error("Section does not belong to circuit list.");
}
}
private assertUniqueEquipmentIdentifier(
database: AppDatabase,
circuit: CircuitRow,
equipmentIdentifier: string | undefined
) {
if (
equipmentIdentifier === undefined ||
equipmentIdentifier === circuit.equipmentIdentifier
) {
return;
}
const duplicate = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, circuit.circuitListId),
eq(circuits.equipmentIdentifier, equipmentIdentifier),
ne(circuits.id, circuit.id)
)
)
.get();
if (duplicate) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
}
}
function getCircuitFieldValue<TField extends CircuitUpdateField>(
circuit: CircuitRow,
field: TField
): CircuitUpdateValues[TField] {
if (field === "isReserve") {
return Boolean(circuit.isReserve) as CircuitUpdateValues[TField];
}
return circuit[field] as unknown as CircuitUpdateValues[TField];
}
@@ -0,0 +1,235 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitSectionRenumberProjectCommand,
createCircuitSectionRenumberProjectCommand,
type CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model.js";
import type {
CircuitSectionRenumberProjectCommandStore,
ExecuteCircuitSectionRenumberCommandInput,
} from "../../domain/ports/circuit-section-renumber-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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitSectionRenumberProjectCommandRepository
implements CircuitSectionRenumberProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitSectionRenumberCommandInput) {
assertCircuitSectionRenumberProjectCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitSectionRenumberCommandInput
) {
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 sectionCircuits = tx
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.sectionId, section.id))
.all();
const assignments = input.command.payload.assignments;
if (
sectionCircuits.length !== assignments.length ||
sectionCircuits.some(
(circuit) =>
circuit.circuitListId !== section.circuitListId
)
) {
throw new Error(
"Circuit renumber must include every circuit in the section."
);
}
const circuitsById = new Map(
sectionCircuits.map((circuit) => [circuit.id, circuit])
);
for (const assignment of assignments) {
const circuit = circuitsById.get(assignment.circuitId);
if (
!circuit ||
circuit.equipmentIdentifier !==
assignment.expectedEquipmentIdentifier
) {
throw new Error(
"Circuit changed before section renumber execution."
);
}
}
const listCircuits = tx
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.circuitListId, section.circuitListId))
.all();
const sectionCircuitIds = new Set(
assignments.map((assignment) => assignment.circuitId)
);
const targetIdentifiers = new Set(
assignments.map(
(assignment) => assignment.targetEquipmentIdentifier
)
);
const conflictingCircuit = listCircuits.find(
(circuit) =>
!sectionCircuitIds.has(circuit.id) &&
targetIdentifiers.has(circuit.equipmentIdentifier)
);
if (conflictingCircuit) {
throw new Error(
"Target equipment identifier already exists in circuit list."
);
}
const inverse = createCircuitSectionRenumberProjectCommand(
section.id,
assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedEquipmentIdentifier:
assignment.targetEquipmentIdentifier,
targetEquipmentIdentifier:
assignment.expectedEquipmentIdentifier,
}))
);
const temporaryIdentifiers = this.createTemporaryIdentifiers(
section.id,
assignments,
listCircuits.map((circuit) => circuit.equipmentIdentifier)
);
for (const assignment of assignments) {
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({ equipmentIdentifier: temporaryIdentifier })
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(circuits.circuitListId, section.circuitListId),
eq(
circuits.equipmentIdentifier,
assignment.expectedEquipmentIdentifier
)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during section renumber execution."
);
}
}
for (const assignment of assignments) {
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({
equipmentIdentifier:
assignment.targetEquipmentIdentifier,
})
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(circuits.circuitListId, section.circuitListId),
eq(
circuits.equipmentIdentifier,
temporaryIdentifier
)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during final section renumber execution."
);
}
}
return inverse;
}
private createTemporaryIdentifiers(
sectionId: string,
assignments: CircuitSectionRenumberAssignment[],
occupiedIdentifiers: string[]
) {
const occupied = new Set([
...occupiedIdentifiers,
...assignments.map(
(assignment) => assignment.targetEquipmentIdentifier
),
]);
const temporaryIdentifiers = new Map<string, string>();
for (let index = 0; index < assignments.length; index += 1) {
const assignment = assignments[index];
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const base = `__tmp_renumber_command_${sectionId}_${index}`;
let candidate = base;
while (occupied.has(candidate)) {
candidate = `${candidate}_`;
}
occupied.add(candidate);
temporaryIdentifiers.set(assignment.circuitId, candidate);
}
return temporaryIdentifiers;
}
}
@@ -0,0 +1,202 @@
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,
} from "../../domain/ports/circuit-section-reorder-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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
type ReorderHistoryCommand =
| CircuitSectionReorderProjectCommand
| CircuitSectionsReorderProjectCommand;
export class CircuitSectionReorderProjectCommandRepository
implements CircuitSectionReorderProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitSectionReorderCommandInput) {
this.assertSupportedCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitSectionReorderCommandInput
) {
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 inverse =
input.command.type === circuitSectionReorderCommandType
? createCircuitSectionReorderProjectCommand(
inverseSections[0].sectionId,
inverseSections[0].assignments
)
: createCircuitSectionsReorderProjectCommand(
inverseSections
);
return 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,15 +1,28 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import { db } from "../client.js";
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
import type { AppDatabase } from "../database-context.js";
import { circuitSections } from "../schema/circuit-sections.js";
export function createDefaultCircuitSectionValues(circuitListId: string) {
return defaultCircuitSectionDefinitions.map((entry) => ({
id: crypto.randomUUID(),
circuitListId,
...entry,
}));
}
export class CircuitSectionRepository {
constructor(private readonly database: AppDatabase) {}
async findById(sectionId: string) {
const db = this.database;
const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1);
return row ?? null;
}
async listByCircuitList(circuitListId: string) {
const db = this.database;
return db
.select()
.from(circuitSections)
@@ -18,14 +31,8 @@ export class CircuitSectionRepository {
}
async createDefaults(circuitListId: string) {
const defaults = [
{ 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 },
];
for (const entry of defaults) {
const db = this.database;
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
const existing = await db
.select({ id: circuitSections.id })
.from(circuitSections)
@@ -36,11 +43,7 @@ export class CircuitSectionRepository {
if (existing.length) {
continue;
}
await db.insert(circuitSections).values({
id: crypto.randomUUID(),
circuitListId,
...entry,
});
await db.insert(circuitSections).values(entry);
}
}
}
@@ -0,0 +1,295 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import {
assertCircuitDeleteProjectCommand,
assertCircuitInsertProjectCommand,
circuitDeleteCommandType,
circuitInsertCommandType,
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
type CircuitSnapshot,
type CircuitStructureProjectCommand,
} from "../../domain/models/circuit-structure-project-command.model.js";
import type {
CircuitStructureProjectCommandStore,
ExecuteCircuitStructureCommandInput,
} from "../../domain/ports/circuit-structure-project-command.store.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.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 {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitStructureCommandInput) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(
tx,
input.projectId,
input.source,
input.command
)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
command: CircuitStructureProjectCommand
): CircuitStructureProjectCommand {
if (command.type === circuitInsertCommandType) {
assertCircuitInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.circuit
);
}
if (command.type === circuitDeleteCommandType) {
assertCircuitDeleteProjectCommand(command);
return this.delete(
database,
projectId,
command.payload.circuitId,
command.payload.expectedCircuitListId
);
}
throw new Error("Unsupported circuit structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
snapshot: CircuitSnapshot
) {
this.assertCircuitLocation(database, projectId, snapshot);
if (source === "user") {
const derivedVoltage = resolveCircuitVoltage(
database,
projectId,
snapshot.sectionId,
snapshot.deviceRows.map((row) => row.phaseType)
);
if (snapshot.voltage !== derivedVoltage) {
throw new Error(
"Circuit voltage must match the project phase voltage."
);
}
}
const existingCircuit = database
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, snapshot.id))
.get();
if (existingCircuit) {
throw new Error("Circuit id already exists.");
}
const duplicateIdentifier = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, snapshot.circuitListId),
eq(
circuits.equipmentIdentifier,
snapshot.equipmentIdentifier
)
)
)
.get();
if (duplicateIdentifier) {
throw new Error(
"Duplicate equipmentIdentifier in circuit list."
);
}
if (snapshot.deviceRows.length > 0) {
const rowIds = snapshot.deviceRows.map((row) => row.id);
const existingRow = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
.limit(1)
.get();
if (existingRow) {
throw new Error("Circuit device-row id already exists.");
}
for (const row of snapshot.deviceRows) {
if (source === "user" && row.legacyConsumerId !== null) {
throw new Error(
"User commands cannot assign legacy consumer ids."
);
}
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid.");
}
assertCircuitDeviceRowReferencesInProject(
database,
projectId,
row
);
}
}
database
.insert(circuits)
.values({
id: snapshot.id,
circuitListId: snapshot.circuitListId,
sectionId: snapshot.sectionId,
equipmentIdentifier: snapshot.equipmentIdentifier,
displayName: snapshot.displayName,
sortOrder: snapshot.sortOrder,
protectionType: snapshot.protectionType,
protectionRatedCurrent: snapshot.protectionRatedCurrent,
protectionCharacteristic: snapshot.protectionCharacteristic,
cableType: snapshot.cableType,
cableCrossSection: snapshot.cableCrossSection,
cableLength: snapshot.cableLength,
rcdAssignment: snapshot.rcdAssignment,
terminalDesignation: snapshot.terminalDesignation,
voltage: snapshot.voltage,
controlRequirement: snapshot.controlRequirement,
status: snapshot.status,
isReserve: snapshot.isReserve ? 1 : 0,
remark: snapshot.remark,
})
.run();
if (snapshot.deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
}
return createCircuitDeleteProjectCommand(
snapshot.id,
snapshot.circuitListId
);
}
private delete(
database: AppDatabase,
projectId: string,
circuitId: string,
expectedCircuitListId: string
) {
const circuit = database
.select()
.from(circuits)
.where(eq(circuits.id, circuitId))
.get();
if (!circuit || circuit.circuitListId !== expectedCircuitListId) {
throw new Error("Circuit changed before command execution.");
}
const owningList = database
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, circuit.circuitListId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!owningList) {
throw new Error("Circuit does not belong to project.");
}
const rows = database
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuit.id))
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
const inverse = createCircuitInsertProjectCommand({
id: circuit.id,
circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId,
equipmentIdentifier: circuit.equipmentIdentifier,
displayName: circuit.displayName,
sortOrder: circuit.sortOrder,
protectionType: circuit.protectionType,
protectionRatedCurrent: circuit.protectionRatedCurrent,
protectionCharacteristic: circuit.protectionCharacteristic,
cableType: circuit.cableType,
cableCrossSection: circuit.cableCrossSection,
cableLength: circuit.cableLength,
rcdAssignment: circuit.rcdAssignment,
terminalDesignation: circuit.terminalDesignation,
voltage: circuit.voltage,
controlRequirement: circuit.controlRequirement,
status: circuit.status,
isReserve: Boolean(circuit.isReserve),
remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
});
const result = database
.delete(circuits)
.where(
and(
eq(circuits.id, circuitId),
eq(circuits.circuitListId, expectedCircuitListId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("Circuit could not be deleted.");
}
return inverse;
}
private assertCircuitLocation(
database: AppDatabase,
projectId: string,
snapshot: CircuitSnapshot
) {
const list = database
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, snapshot.circuitListId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!list) {
throw new Error("Circuit list does not belong to project.");
}
const section = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(
and(
eq(circuitSections.id, snapshot.sectionId),
eq(
circuitSections.circuitListId,
snapshot.circuitListId
)
)
)
.get();
if (!section) {
throw new Error("Section does not belong to circuit list.");
}
}
}
+102
View File
@@ -0,0 +1,102 @@
export interface CircuitCreatePersistenceInput {
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;
isReserve?: boolean;
}
export interface CircuitPatchPersistenceInput {
sectionId?: string;
equipmentIdentifier?: string;
displayName?: string | null;
sortOrder?: number;
protectionType?: string | null;
protectionRatedCurrent?: number | null;
protectionCharacteristic?: string | null;
cableType?: string | null;
cableCrossSection?: string | null;
cableLength?: number | null;
voltage?: number | null;
controlRequirement?: string | null;
remark?: string | null;
rcdAssignment?: string | null;
terminalDesignation?: string | null;
status?: string | null;
isReserve?: boolean;
}
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
return {
id,
circuitListId: input.circuitListId,
sectionId: input.sectionId,
equipmentIdentifier: input.equipmentIdentifier,
displayName: input.displayName ?? null,
sortOrder: input.sortOrder,
protectionType: input.protectionType ?? null,
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
protectionCharacteristic: input.protectionCharacteristic ?? null,
cableType: input.cableType ?? null,
cableCrossSection: input.cableCrossSection ?? null,
cableLength: input.cableLength ?? null,
rcdAssignment: input.rcdAssignment ?? null,
terminalDesignation: input.terminalDesignation ?? null,
voltage: input.voltage ?? null,
controlRequirement: input.controlRequirement ?? null,
status: input.status ?? null,
isReserve: input.isReserve ? 1 : 0,
remark: input.remark ?? null,
};
}
export function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
const values: Partial<ReturnType<typeof toCircuitCreateValues>> = {};
const has = (field: keyof CircuitPatchPersistenceInput) =>
Object.prototype.hasOwnProperty.call(input, field);
if (input.sectionId !== undefined) values.sectionId = input.sectionId;
if (input.equipmentIdentifier !== undefined) {
values.equipmentIdentifier = input.equipmentIdentifier;
}
if (has("displayName")) values.displayName = input.displayName ?? null;
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
if (has("protectionType")) values.protectionType = input.protectionType ?? null;
if (has("protectionRatedCurrent")) {
values.protectionRatedCurrent = input.protectionRatedCurrent ?? null;
}
if (has("protectionCharacteristic")) {
values.protectionCharacteristic = input.protectionCharacteristic ?? null;
}
if (has("cableType")) values.cableType = input.cableType ?? null;
if (has("cableCrossSection")) {
values.cableCrossSection = input.cableCrossSection ?? null;
}
if (has("cableLength")) values.cableLength = input.cableLength ?? null;
if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null;
if (has("terminalDesignation")) {
values.terminalDesignation = input.terminalDesignation ?? null;
}
if (has("voltage")) values.voltage = input.voltage ?? null;
if (has("controlRequirement")) {
values.controlRequirement = input.controlRequirement ?? null;
}
if (has("status")) values.status = input.status ?? null;
if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0;
if (has("remark")) values.remark = input.remark ?? null;
return values;
}
+5 -165
View File
@@ -1,15 +1,12 @@
import crypto from "node:crypto";
import { and, asc, eq, inArray, ne } from "drizzle-orm";
import { db } from "../client.js";
import { asc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { circuits } from "../schema/circuits.js";
export class CircuitRepository {
async findById(circuitId: string) {
const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1);
return row ?? null;
}
constructor(private readonly database: AppDatabase) {}
async listByCircuitList(circuitListId: string) {
const db = this.database;
return db
.select()
.from(circuits)
@@ -17,169 +14,12 @@ export class CircuitRepository {
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
}
async create(input: {
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;
isReserve?: boolean;
}) {
const id = crypto.randomUUID();
await db.insert(circuits).values({
id,
circuitListId: input.circuitListId,
sectionId: input.sectionId,
equipmentIdentifier: input.equipmentIdentifier,
displayName: input.displayName ?? null,
sortOrder: input.sortOrder,
protectionType: input.protectionType ?? null,
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
protectionCharacteristic: input.protectionCharacteristic ?? null,
cableType: input.cableType ?? null,
cableCrossSection: input.cableCrossSection ?? null,
cableLength: input.cableLength ?? null,
rcdAssignment: input.rcdAssignment ?? null,
terminalDesignation: input.terminalDesignation ?? null,
voltage: input.voltage ?? null,
controlRequirement: input.controlRequirement ?? null,
status: input.status ?? null,
isReserve: input.isReserve ? 1 : 0,
remark: input.remark ?? null,
});
return id;
}
async update(
circuitId: string,
input: {
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
}
) {
await db
.update(circuits)
.set({
sectionId: input.sectionId,
equipmentIdentifier: input.equipmentIdentifier,
displayName: input.displayName ?? null,
sortOrder: input.sortOrder,
protectionType: input.protectionType ?? null,
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
protectionCharacteristic: input.protectionCharacteristic ?? null,
cableType: input.cableType ?? null,
cableCrossSection: input.cableCrossSection ?? null,
cableLength: input.cableLength ?? null,
rcdAssignment: input.rcdAssignment ?? null,
terminalDesignation: input.terminalDesignation ?? null,
voltage: input.voltage ?? null,
controlRequirement: input.controlRequirement ?? null,
status: input.status ?? null,
isReserve: input.isReserve ? 1 : 0,
remark: input.remark ?? null,
})
.where(eq(circuits.id, circuitId));
}
async delete(circuitId: string) {
await db.delete(circuits).where(eq(circuits.id, circuitId));
}
async existsByEquipmentIdentifier(circuitListId: string, equipmentIdentifier: string, excludeCircuitId?: string) {
const rows = await db
.select({ id: circuits.id })
.from(circuits)
.where(
excludeCircuitId
? and(
eq(circuits.circuitListId, circuitListId),
eq(circuits.equipmentIdentifier, equipmentIdentifier),
ne(circuits.id, excludeCircuitId)
)
: and(eq(circuits.circuitListId, circuitListId), eq(circuits.equipmentIdentifier, equipmentIdentifier))
)
.limit(1);
return Boolean(rows.length);
}
async listBySection(sectionId: string) {
const db = this.database;
return db
.select()
.from(circuits)
.where(eq(circuits.sectionId, sectionId))
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
}
async updateEquipmentIdentifiersSafely(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
) {
if (updates.length === 0) {
return;
}
// better-sqlite3 transactions are synchronous callbacks. Do not make this callback
// async or return a Promise, otherwise statements may run outside the transaction scope.
db.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.");
}
// Direct identifier swaps can violate UNIQUE(circuit_list_id, equipment_identifier)
// mid-update (for example A->B while B->A). Two-phase strategy prevents that:
// 1) assign unique temporary identifiers for all affected circuits
// 2) assign final user-visible identifiers
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();
}
});
}
}
-147
View File
@@ -1,147 +0,0 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { db } from "../client.js";
import { consumers } from "../schema/consumers.js";
import type {
CreateConsumerInput,
UpdateConsumerInput,
} from "../../shared/validation/consumer.schemas.js";
export class ConsumerRepository {
async listByProject(projectId: string) {
return db.select().from(consumers).where(eq(consumers.projectId, projectId));
}
async listByCircuitList(circuitListId: string) {
return db.select().from(consumers).where(eq(consumers.circuitListId, circuitListId));
}
async create(input: CreateConsumerInput) {
const id = crypto.randomUUID();
const normalizedName = input.name?.trim() || "Unbenannter Eintrag";
const normalizedQuantity = input.quantity ?? 0;
const normalizedInstalledPowerPerUnitKw = input.installedPowerPerUnitKw ?? 0;
const normalizedDemandFactor = input.demandFactor ?? 1;
await db.insert(consumers).values({
id,
projectId: input.projectId,
distributionBoardId: input.distributionBoardId ?? null,
circuitListId: input.circuitListId ?? null,
projectDeviceId: input.projectDeviceId ?? null,
isLinkedToDevice: input.isLinkedToDevice ? 1 : 0,
roomId: input.roomId ?? null,
circuitNumber: input.circuitNumber ?? null,
description: input.description ?? null,
name: normalizedName,
category: input.category ?? null,
deviceType: input.deviceType ?? null,
phaseType: input.phaseType ?? null,
tradeOrCostGroup: input.tradeOrCostGroup ?? null,
group: input.group ?? null,
protectionType: input.protectionType ?? null,
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
protectionCharacteristic: input.protectionCharacteristic ?? null,
cableType: input.cableType ?? null,
cableCrossSection: input.cableCrossSection ?? null,
comment: input.comment ?? null,
quantity: normalizedQuantity,
installedPowerPerUnitKw: normalizedInstalledPowerPerUnitKw,
demandFactor: normalizedDemandFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
});
return {
id,
...input,
name: normalizedName,
quantity: normalizedQuantity,
installedPowerPerUnitKw: normalizedInstalledPowerPerUnitKw,
demandFactor: normalizedDemandFactor,
};
}
async update(consumerId: string, input: UpdateConsumerInput) {
const normalizedName = input.name?.trim() || "Unbenannter Eintrag";
const normalizedQuantity = input.quantity ?? 0;
const normalizedInstalledPowerPerUnitKw = input.installedPowerPerUnitKw ?? 0;
const normalizedDemandFactor = input.demandFactor ?? 1;
await db
.update(consumers)
.set({
projectId: input.projectId,
distributionBoardId: input.distributionBoardId ?? null,
circuitListId: input.circuitListId ?? null,
projectDeviceId: input.projectDeviceId ?? null,
isLinkedToDevice: input.isLinkedToDevice ? 1 : 0,
roomId: input.roomId ?? null,
circuitNumber: input.circuitNumber ?? null,
description: input.description ?? null,
name: normalizedName,
category: input.category ?? null,
deviceType: input.deviceType ?? null,
phaseType: input.phaseType ?? null,
tradeOrCostGroup: input.tradeOrCostGroup ?? null,
group: input.group ?? null,
protectionType: input.protectionType ?? null,
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
protectionCharacteristic: input.protectionCharacteristic ?? null,
cableType: input.cableType ?? null,
cableCrossSection: input.cableCrossSection ?? null,
comment: input.comment ?? null,
quantity: normalizedQuantity,
installedPowerPerUnitKw: normalizedInstalledPowerPerUnitKw,
demandFactor: normalizedDemandFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
})
.where(eq(consumers.id, consumerId));
}
async findById(consumerId: string) {
const [row] = await db.select().from(consumers).where(eq(consumers.id, consumerId)).limit(1);
return row ?? null;
}
async delete(consumerId: string) {
await db.delete(consumers).where(eq(consumers.id, consumerId));
}
async syncLinkedConsumersFromProjectDevice(
projectId: string,
projectDeviceId: string,
data: {
displayName: string;
category?: string;
quantity: number;
installedPowerPerUnitKw: number;
demandFactor: number;
phaseCount?: 1 | 3;
powerFactor?: number;
note?: string;
}
) {
await db
.update(consumers)
.set({
name: data.displayName,
category: data.category ?? null,
quantity: data.quantity,
installedPowerPerUnitKw: data.installedPowerPerUnitKw,
demandFactor: data.demandFactor,
phaseCount: data.phaseCount ?? null,
powerFactor: data.powerFactor ?? null,
note: data.note ?? null,
})
.where(
and(
eq(consumers.projectId, projectId),
eq(consumers.projectDeviceId, projectDeviceId),
eq(consumers.isLinkedToDevice, 1)
)
);
}
}
@@ -0,0 +1,428 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
createDistributionBoardDeleteProjectCommand,
createDistributionBoardInsertProjectCommand,
distributionBoardDeleteCommandType,
distributionBoardInsertCommandType,
normalizeDistributionBoardStructureProjectCommand,
type DistributionBoardStructureProjectCommand,
type DistributionBoardStructureSnapshot,
legacyDistributionBoardStructureCommandSchemaVersion,
} from "../../domain/models/distribution-board-structure-project-command.model.js";
import {
assertDistributionBoardUpdateProjectCommand,
createDistributionBoardUpdateProjectCommand,
legacyDistributionBoardUpdateCommandSchemaVersion,
type DistributionBoardUpdateField,
type DistributionBoardUpdatePatch,
type DistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateValues,
} from "../../domain/models/distribution-board-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 { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class DistributionBoardStructureProjectCommandRepository
implements DistributionBoardStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteDistributionBoardStructureCommandInput) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(tx, input.projectId, input.command)
);
}
executeUpdate(
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardUpdateProjectCommand;
}
) {
assertDistributionBoardUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.update(tx, input)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: DistributionBoardStructureProjectCommand
): DistributionBoardStructureProjectCommand {
const normalized =
normalizeDistributionBoardStructureProjectCommand(command);
if (normalized.type === distributionBoardInsertCommandType) {
assertDistributionBoardInsertProjectCommand(normalized);
const inverse = this.insert(
database,
projectId,
normalized.payload.structure
);
return command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
? toLegacyStructureCommand(inverse)
: inverse;
}
if (normalized.type === distributionBoardDeleteCommandType) {
assertDistributionBoardDeleteProjectCommand(normalized);
const inverse = this.delete(
database,
projectId,
normalized.payload.structure
);
return command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
? toLegacyStructureCommand(inverse)
: inverse;
}
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,
enabledSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
})
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
assertSupplyTypeEnabled(
project.enabledSupplyTypes,
structure.distributionBoard.supplyType
);
if (structure.distributionBoard.floorId !== null) {
const floor = database
.select({ projectId: floors.projectId })
.from(floors)
.where(eq(floors.id, structure.distributionBoard.floorId))
.get();
if (!floor || floor.projectId !== projectId) {
throw new Error(
"Distribution-board floor does not belong to project."
);
}
}
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);
}
private update(
database: AppDatabase,
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardUpdateProjectCommand;
}
) {
const current = database
.select()
.from(distributionBoards)
.where(
and(
eq(
distributionBoards.id,
input.command.payload.distributionBoardId
),
eq(distributionBoards.projectId, input.projectId)
)
)
.get();
if (!current) {
throw new Error("Distribution board does not belong to project.");
}
const project = database
.select({
enabledSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
})
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as DistributionBoardUpdatePatch;
if (patch.floorId !== undefined && patch.floorId !== null) {
const floor = database
.select({ projectId: floors.projectId })
.from(floors)
.where(eq(floors.id, patch.floorId))
.get();
if (!floor || floor.projectId !== input.projectId) {
throw new Error(
"Distribution-board floor does not belong to project."
);
}
}
if (patch.supplyType !== undefined) {
assertSupplyTypeEnabled(
project.enabledSupplyTypes,
patch.supplyType
);
}
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
getDistributionBoardFieldValue(current, change.field),
])
) as DistributionBoardUpdatePatch;
const currentInverse = createDistributionBoardUpdateProjectCommand(
current.id,
inversePatch
);
const inverse =
input.command.schemaVersion ===
legacyDistributionBoardUpdateCommandSchemaVersion
? ({
...currentInverse,
schemaVersion:
legacyDistributionBoardUpdateCommandSchemaVersion,
} as DistributionBoardUpdateProjectCommand)
: currentInverse;
const updated = database
.update(distributionBoards)
.set(patch)
.where(
and(
eq(distributionBoards.id, current.id),
eq(distributionBoards.projectId, input.projectId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Distribution board changed before command execution."
);
}
return inverse;
}
}
function assertSupplyTypeEnabled(
enabledSupplyTypes: (typeof projects.$inferSelect)["enabledDistributionBoardSupplyTypes"],
supplyType: (typeof distributionBoards.$inferSelect)["supplyType"]
) {
if (
supplyType !== null &&
!enabledSupplyTypes.includes(supplyType)
) {
throw new Error(
`Die Netzart ${supplyType} ist in den Projekteinstellungen nicht aktiviert.`
);
}
}
function toLegacyStructureCommand(
command:
| ReturnType<typeof createDistributionBoardInsertProjectCommand>
| ReturnType<typeof createDistributionBoardDeleteProjectCommand>
): DistributionBoardStructureProjectCommand {
const {
floorId: _floorId,
supplyType: _supplyType,
...distributionBoard
} = command.payload.structure.distributionBoard;
return {
schemaVersion: legacyDistributionBoardStructureCommandSchemaVersion,
type: command.type,
payload: {
structure: {
...command.payload.structure,
distributionBoard,
},
},
} as DistributionBoardStructureProjectCommand;
}
function getDistributionBoardFieldValue<
TField extends DistributionBoardUpdateField,
>(
distributionBoard: typeof distributionBoards.$inferSelect,
field: TField
): DistributionBoardUpdateValues[TField] {
return distributionBoard[field] as DistributionBoardUpdateValues[TField];
}
function structureMatches(
expected: DistributionBoardStructureSnapshot,
actual: {
board: typeof distributionBoards.$inferSelect;
list: typeof circuitLists.$inferSelect;
sections: Array<typeof circuitSections.$inferSelect>;
}
) {
const {
simultaneityFactor,
...actualDistributionBoard
} = actual.board;
if (
simultaneityFactor !== 1 ||
!sameRecord(
expected.distributionBoard,
actualDistributionBoard
) ||
!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
)
);
}
@@ -1,23 +1,21 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { db } from "../client.js";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
export class DistributionBoardRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) {
return db.select().from(distributionBoards).where(eq(distributionBoards.projectId, projectId));
return this.database
.select()
.from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId));
}
async create(projectId: string, name: string) {
const id = crypto.randomUUID();
const board = { id, projectId, name };
await db.insert(distributionBoards).values(board);
return board;
}
async existsInProject(projectId: string, distributionBoardId: string) {
const [row] = await db
.select({ id: distributionBoards.id })
async findById(projectId: string, distributionBoardId: string) {
return (
this.database
.select()
.from(distributionBoards)
.where(
and(
@@ -25,7 +23,8 @@ export class DistributionBoardRepository {
eq(distributionBoards.id, distributionBoardId)
)
)
.limit(1);
return Boolean(row);
.get() ?? null
);
}
}
+5 -25
View File
@@ -1,36 +1,16 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import { db } from "../client.js";
import { asc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { floors } from "../schema/floors.js";
export class FloorRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) {
const db = this.database;
return db
.select()
.from(floors)
.where(eq(floors.projectId, projectId))
.orderBy(asc(floors.sortOrder), asc(floors.name));
}
async create(projectId: string, name: string) {
const id = crypto.randomUUID();
const existing = await this.listByProject(projectId);
const floor = {
id,
projectId,
name,
sortOrder: existing.length,
};
await db.insert(floors).values(floor);
return floor;
}
async existsInProject(projectId: string, floorId: string) {
const [row] = await db
.select({ id: floors.id })
.from(floors)
.where(and(eq(floors.projectId, projectId), eq(floors.id, floorId)))
.limit(1);
return Boolean(row);
}
}
@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "../client.js";
import type { AppDatabase } from "../database-context.js";
import { globalDevices } from "../schema/global-devices.js";
import type {
CreateGlobalDeviceInput,
@@ -8,11 +8,15 @@ import type {
} from "../../shared/validation/global-device.schemas.js";
export class GlobalDeviceRepository {
constructor(private readonly database: AppDatabase) {}
async list() {
const db = this.database;
return db.select().from(globalDevices);
}
async create(input: CreateGlobalDeviceInput) {
const db = this.database;
const id = crypto.randomUUID();
await db.insert(globalDevices).values({
id,
@@ -22,8 +26,8 @@ export class GlobalDeviceRepository {
quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
voltageV: null,
phaseCount: input.phaseCount,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
});
@@ -31,6 +35,7 @@ export class GlobalDeviceRepository {
}
async update(globalDeviceId: string, input: UpdateGlobalDeviceInput) {
const db = this.database;
await db
.update(globalDevices)
.set({
@@ -40,8 +45,8 @@ export class GlobalDeviceRepository {
quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
voltageV: null,
phaseCount: input.phaseCount,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
})
@@ -49,11 +54,13 @@ export class GlobalDeviceRepository {
}
async findById(globalDeviceId: string) {
const db = this.database;
const [row] = await db.select().from(globalDevices).where(eq(globalDevices.id, globalDeviceId)).limit(1);
return row ?? null;
}
async delete(globalDeviceId: string) {
const db = this.database;
await db.delete(globalDevices).where(eq(globalDevices.id, globalDeviceId));
}
}
@@ -0,0 +1,142 @@
import crypto from "node:crypto";
import { eq, inArray, isNull } from "drizzle-orm";
import type {
LegacyMigrationCircuitInput,
LegacyMigrationReportInput,
} from "../../domain/ports/legacy-consumer-migration.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuits } from "../schema/circuits.js";
import { consumers } from "../schema/consumers.js";
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
import { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
import { toCircuitCreateValues } from "./circuit.persistence.js";
export class LegacyConsumerMigrationRepository {
constructor(private readonly database: AppDatabase) {}
async listSourceConsumersByCircuitList(circuitListId: string) {
return this.database
.select()
.from(consumers)
.where(eq(consumers.circuitListId, circuitListId));
}
async listUnmigratedConsumers() {
return this.database
.select({
id: consumers.id,
projectId: consumers.projectId,
circuitListId: consumers.circuitListId,
})
.from(consumers)
.leftJoin(
legacyConsumerCircuitMigrations,
eq(legacyConsumerCircuitMigrations.consumerId, consumers.id)
)
.where(isNull(legacyConsumerCircuitMigrations.consumerId));
}
async listMigratedConsumerIds(circuitListId: string) {
const rows = await this.database
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
.from(legacyConsumerCircuitMigrations)
.where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId));
return rows.map((row) => row.consumerId);
}
persistCircuitListMigration(input: {
circuitListId: string;
circuits: LegacyMigrationCircuitInput[];
report: LegacyMigrationReportInput;
}) {
if (input.circuits.some((entry) => entry.circuit.circuitListId !== input.circuitListId)) {
throw new Error("All migrated circuits must belong to the target circuit list.");
}
const consumerIds = input.circuits.flatMap((entry) =>
entry.deviceRows.map((row) => row.legacyConsumerId)
);
if (new Set(consumerIds).size !== consumerIds.length) {
throw new Error("A legacy consumer may only be migrated once per operation.");
}
const preparedCircuits = input.circuits.map((entry) => {
const circuitId = crypto.randomUUID();
return {
circuitId,
circuitValues: toCircuitCreateValues(circuitId, entry.circuit),
rows: entry.deviceRows.map((row) => {
const rowId = crypto.randomUUID();
return {
consumerId: row.legacyConsumerId,
rowId,
rowValues: toCircuitDeviceRowCreateValues(rowId, {
...row,
circuitId,
}),
};
}),
};
});
const createdAtIso = new Date().toISOString();
this.database.transaction((tx) => {
if (consumerIds.length > 0) {
const existingMappings = tx
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
.from(legacyConsumerCircuitMigrations)
.where(inArray(legacyConsumerCircuitMigrations.consumerId, consumerIds))
.all();
if (existingMappings.length > 0) {
throw new Error("A legacy consumer was migrated before this operation completed.");
}
}
for (const preparedCircuit of preparedCircuits) {
tx.insert(circuits).values(preparedCircuit.circuitValues).run();
for (const row of preparedCircuit.rows) {
tx.insert(circuitDeviceRows).values(row.rowValues).run();
tx
.insert(legacyConsumerCircuitMigrations)
.values({
consumerId: row.consumerId,
circuitId: preparedCircuit.circuitId,
circuitDeviceRowId: row.rowId,
circuitListId: input.circuitListId,
createdAtIso,
})
.run();
}
}
const reportValues = {
...input.report,
createdAtIso,
};
const existingReport = tx
.select({ id: legacyConsumerMigrationReports.id })
.from(legacyConsumerMigrationReports)
.where(eq(legacyConsumerMigrationReports.circuitListId, input.circuitListId))
.limit(1)
.all();
if (existingReport.length > 0) {
tx
.update(legacyConsumerMigrationReports)
.set(reportValues)
.where(eq(legacyConsumerMigrationReports.id, existingReport[0].id))
.run();
} else {
tx
.insert(legacyConsumerMigrationReports)
.values({
id: crypto.randomUUID(),
circuitListId: input.circuitListId,
...reportValues,
})
.run();
}
});
}
}
@@ -0,0 +1,96 @@
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export interface ProjectCommandTransactionInput<
Command extends SerializedProjectCommand<unknown>,
> {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: Command;
}
export interface AppliedProjectCommand<
Forward extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
> {
forward: Forward;
inverse: Inverse;
}
export function executeProjectCommandTransaction<
Command extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
>(
database: AppDatabase,
input: ProjectCommandTransactionInput<Command>,
applyCommand: (transaction: AppDatabase) => Inverse
): { revision: AppendedProjectRevision; inverse: Inverse } {
return executeAppliedProjectCommandTransaction(
database,
input,
(transaction) => ({
forward: input.command,
inverse: applyCommand(transaction),
})
);
}
export function executeProjectCommandTransactionWithAppliedForward<
Command extends SerializedProjectCommand<unknown>,
Forward extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
>(
database: AppDatabase,
input: ProjectCommandTransactionInput<Command>,
applyCommand: (
transaction: AppDatabase
) => AppliedProjectCommand<Forward, Inverse>
): { revision: AppendedProjectRevision; inverse: Inverse } {
return executeAppliedProjectCommandTransaction(
database,
input,
applyCommand
);
}
function executeAppliedProjectCommandTransaction<
Command extends SerializedProjectCommand<unknown>,
Forward extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
>(
database: AppDatabase,
input: ProjectCommandTransactionInput<Command>,
applyCommand: (
transaction: AppDatabase
) => AppliedProjectCommand<Forward, Inverse>
): { revision: AppendedProjectRevision; inverse: Inverse } {
return database.transaction((tx) => {
const { forward, inverse } = applyCommand(tx);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
@@ -0,0 +1,134 @@
import { and, eq } from "drizzle-orm";
import {
assertProjectDeviceUpdateProjectCommand,
createProjectDeviceUpdateProjectCommand,
type ProjectDeviceUpdateField,
type ProjectDeviceUpdatePatch,
type ProjectDeviceUpdateValues,
} from "../../domain/models/project-device-project-command.model.js";
import type {
ExecuteProjectDeviceUpdateCommandInput,
ProjectDeviceProjectCommandStore,
} from "../../domain/ports/project-device-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectDevices } from "../schema/project-devices.js";
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js";
type ProjectDeviceRow = typeof projectDevices.$inferSelect;
export class ProjectDeviceProjectCommandRepository
implements ProjectDeviceProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
assertProjectDeviceUpdateProjectCommand(input.command);
return executeProjectCommandTransactionWithAppliedForward(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteProjectDeviceUpdateCommandInput
) {
const current = tx
.select()
.from(projectDevices)
.where(
and(
eq(
projectDevices.id,
input.command.payload.projectDeviceId
),
eq(projectDevices.projectId, input.projectId)
)
)
.get();
if (!current) {
throw new Error(
"Project device does not belong to project."
);
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as ProjectDeviceUpdatePatch;
if (input.source === "user") {
if (
input.command.payload.changes.every(
(change) => change.field === "voltageV"
)
) {
throw new Error(
"Project-device voltage is derived and cannot be edited."
);
}
const targetPhaseType = patch.phaseType ?? current.phaseType;
if (
targetPhaseType !== "single_phase" &&
targetPhaseType !== "three_phase"
) {
throw new Error("Project-device phase type is invalid.");
}
const derivedVoltage = resolveProjectDeviceVoltage(
tx,
input.projectId,
targetPhaseType
);
if (derivedVoltage === current.voltageV) {
delete patch.voltageV;
} else {
patch.voltageV = derivedVoltage;
}
}
const appliedForward = createProjectDeviceUpdateProjectCommand(
current.id,
patch
);
const inversePatch = Object.fromEntries(
appliedForward.payload.changes.map((change) => [
change.field,
getProjectDeviceFieldValue(current, change.field),
])
) as ProjectDeviceUpdatePatch;
const inverse = createProjectDeviceUpdateProjectCommand(
current.id,
inversePatch
);
const updated = tx
.update(projectDevices)
.set(patch)
.where(
and(
eq(projectDevices.id, current.id),
eq(projectDevices.projectId, input.projectId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Project device changed before command execution."
);
}
return { forward: appliedForward, inverse };
}
}
function getProjectDeviceFieldValue<
TField extends ProjectDeviceUpdateField,
>(
projectDevice: ProjectDeviceRow,
field: TField
): ProjectDeviceUpdateValues[TField] {
return projectDevice[field] as ProjectDeviceUpdateValues[TField];
}
@@ -0,0 +1,164 @@
import { and, eq, inArray } from "drizzle-orm";
import {
assertProjectDeviceRowSyncProjectCommand,
createProjectDeviceRowSyncProjectCommand,
invertProjectDeviceRowSyncOperation,
projectDeviceSyncRowSnapshotFields,
type ProjectDeviceSyncRowSnapshot,
} from "../../domain/models/project-device-row-sync-project-command.model.js";
import type {
ExecuteProjectDeviceRowSyncCommandInput,
ProjectDeviceRowSyncProjectCommandStore,
} from "../../domain/ports/project-device-row-sync-project-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 { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
export class ProjectDeviceRowSyncProjectCommandRepository
implements ProjectDeviceRowSyncProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectDeviceRowSyncCommandInput) {
assertProjectDeviceRowSyncProjectCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteProjectDeviceRowSyncCommandInput
) {
const projectDevice = tx
.select({ id: projectDevices.id })
.from(projectDevices)
.where(
and(
eq(
projectDevices.id,
input.command.payload.projectDeviceId
),
eq(projectDevices.projectId, input.projectId)
)
)
.get();
if (!projectDevice) {
throw new Error(
"Project device does not belong to project."
);
}
const rowIds = input.command.payload.rows.map(
(row) => row.rowId
);
const persistedRows = tx
.select({
row: circuitDeviceRows,
projectId: circuitLists.projectId,
})
.from(circuitDeviceRows)
.innerJoin(
circuits,
eq(circuits.id, circuitDeviceRows.circuitId)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (
persistedRows.length !== rowIds.length ||
persistedRows.some(
(persisted) => persisted.projectId !== input.projectId
)
) {
throw new Error(
"One or more sync rows do not belong to project."
);
}
const persistedById = new Map(
persistedRows.map((persisted) => [
persisted.row.id,
persisted.row,
])
);
for (const assignment of input.command.payload.rows) {
const persisted = persistedById.get(assignment.rowId);
if (
!persisted ||
!snapshotMatchesRow(assignment.expected, persisted)
) {
throw new Error(
"Project-device row changed before sync execution."
);
}
}
const inverse = createProjectDeviceRowSyncProjectCommand(
projectDevice.id,
invertProjectDeviceRowSyncOperation(
input.command.payload.operation
),
input.command.payload.rows.map((row) => ({
rowId: row.rowId,
expected: row.target,
target: row.expected,
}))
);
for (const assignment of input.command.payload.rows) {
const updated = tx
.update(circuitDeviceRows)
.set(assignment.target)
.where(eq(circuitDeviceRows.id, assignment.rowId))
.run();
if (updated.changes !== 1) {
throw new Error(
"Project-device row changed during sync execution."
);
}
}
const affectedCircuitIds = new Set(
input.command.payload.rows
.filter(
(assignment) =>
assignment.expected.phaseType !==
assignment.target.phaseType
)
.map(
(assignment) =>
persistedById.get(assignment.rowId)!.circuitId
)
);
for (const circuitId of affectedCircuitIds) {
updateDerivedCircuitVoltage(
tx,
input.projectId,
circuitId
);
}
return inverse;
}
}
function snapshotMatchesRow(
snapshot: ProjectDeviceSyncRowSnapshot,
row: CircuitDeviceRow
) {
return projectDeviceSyncRowSnapshotFields.every(
(field) => snapshot[field] === row[field]
);
}
@@ -0,0 +1,339 @@
import {
and,
asc,
eq,
inArray,
isNull,
} from "drizzle-orm";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import {
assertProjectDeviceDeleteProjectCommand,
assertProjectDeviceInsertProjectCommand,
createProjectDeviceDeleteProjectCommand,
createProjectDeviceInsertProjectCommand,
projectDeviceDeleteCommandType,
projectDeviceInsertCommandType,
type ProjectDeviceSnapshot,
type ProjectDeviceStructureProjectCommand,
} from "../../domain/models/project-device-structure-project-command.model.js";
import type {
ExecuteProjectDeviceStructureCommandInput,
ProjectDeviceStructureProjectCommandStore,
} from "../../domain/ports/project-device-structure-project-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 { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js";
const circuitDeviceRowSnapshotFields = [
"id",
"circuitId",
"linkedProjectDeviceId",
"legacyConsumerId",
"sortOrder",
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"level",
"roomId",
"roomNumberSnapshot",
"roomNameSnapshot",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"overriddenFields",
] as const satisfies readonly (keyof CircuitDeviceRowSnapshot)[];
export class ProjectDeviceStructureProjectCommandRepository
implements ProjectDeviceStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectDeviceStructureCommandInput) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(
tx,
input.projectId,
input.source,
input.command
)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
source: ExecuteProjectDeviceStructureCommandInput["source"],
command: ProjectDeviceStructureProjectCommand
): ProjectDeviceStructureProjectCommand {
if (command.type === projectDeviceInsertCommandType) {
assertProjectDeviceInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.projectDevice,
command.payload.linkedRows
);
}
if (command.type === projectDeviceDeleteCommandType) {
assertProjectDeviceDeleteProjectCommand(command);
return this.delete(
database,
projectId,
command.payload.projectDeviceId,
command.payload.expectedProjectId
);
}
throw new Error("Unsupported project-device structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
source: ExecuteProjectDeviceStructureCommandInput["source"],
snapshot: ProjectDeviceSnapshot,
linkedRows: CircuitDeviceRowSnapshot[]
) {
if (snapshot.projectId !== projectId) {
throw new Error(
"Project-device snapshot does not belong to project."
);
}
if (source === "user" && linkedRows.length > 0) {
throw new Error(
"User project-device inserts cannot reconnect existing rows."
);
}
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
if (
source === "user" &&
snapshot.voltageV !==
resolveProjectDeviceVoltage(
database,
projectId,
snapshot.phaseType
)
) {
throw new Error(
"Project-device voltage must match the project phase voltage."
);
}
const existing = database
.select({ id: projectDevices.id })
.from(projectDevices)
.where(eq(projectDevices.id, snapshot.id))
.get();
if (existing) {
throw new Error("Project-device id already exists.");
}
this.assertRestorableRows(database, projectId, linkedRows);
database.insert(projectDevices).values(snapshot).run();
for (const row of linkedRows) {
const updated = database
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: snapshot.id })
.where(
and(
eq(circuitDeviceRows.id, row.id),
isNull(circuitDeviceRows.linkedProjectDeviceId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"A restored project-device row changed during insertion."
);
}
}
return createProjectDeviceDeleteProjectCommand(
snapshot.id,
projectId
);
}
private delete(
database: AppDatabase,
projectId: string,
projectDeviceId: string,
expectedProjectId: string
) {
if (expectedProjectId !== projectId) {
throw new Error(
"Project-device delete project does not match command project."
);
}
const projectDevice = database
.select()
.from(projectDevices)
.where(
and(
eq(projectDevices.id, projectDeviceId),
eq(projectDevices.projectId, projectId)
)
)
.get();
if (!projectDevice) {
throw new Error(
"Project device does not belong to project."
);
}
const linkedRows = database
.select({
row: circuitDeviceRows,
projectId: circuitLists.projectId,
})
.from(circuitDeviceRows)
.innerJoin(
circuits,
eq(circuits.id, circuitDeviceRows.circuitId)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(
eq(
circuitDeviceRows.linkedProjectDeviceId,
projectDevice.id
)
)
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
if (
linkedRows.some((linked) => linked.projectId !== projectId)
) {
throw new Error(
"Project device has linked rows outside its project."
);
}
const inverse = createProjectDeviceInsertProjectCommand(
toProjectDeviceSnapshot(projectDevice),
linkedRows.map(({ row }) => ({
...toCircuitDeviceRowSnapshot(row),
linkedProjectDeviceId: null,
}))
);
const deleted = database
.delete(projectDevices)
.where(
and(
eq(projectDevices.id, projectDevice.id),
eq(projectDevices.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Project device could not be deleted.");
}
return inverse;
}
private assertRestorableRows(
database: AppDatabase,
projectId: string,
snapshots: CircuitDeviceRowSnapshot[]
) {
if (snapshots.length === 0) {
return;
}
const rowIds = snapshots.map((row) => row.id);
const persistedRows = database
.select({
row: circuitDeviceRows,
projectId: circuitLists.projectId,
})
.from(circuitDeviceRows)
.innerJoin(
circuits,
eq(circuits.id, circuitDeviceRows.circuitId)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (
persistedRows.length !== snapshots.length ||
persistedRows.some(
(persisted) => persisted.projectId !== projectId
)
) {
throw new Error(
"One or more restored rows do not belong to project."
);
}
const persistedById = new Map(
persistedRows.map(({ row }) => [
row.id,
toCircuitDeviceRowSnapshot(row),
])
);
for (const snapshot of snapshots) {
const persisted = persistedById.get(snapshot.id);
if (
!persisted ||
!circuitDeviceRowSnapshotFields.every(
(field) => persisted[field] === snapshot[field]
)
) {
throw new Error(
"A restored project-device row changed before insertion."
);
}
}
}
}
function toProjectDeviceSnapshot(
projectDevice: typeof projectDevices.$inferSelect
): ProjectDeviceSnapshot {
if (
projectDevice.phaseType !== "single_phase" &&
projectDevice.phaseType !== "three_phase"
) {
throw new Error("Persisted project-device phase type is invalid.");
}
return {
id: projectDevice.id,
projectId: projectDevice.projectId,
name: projectDevice.name,
displayName: projectDevice.displayName,
phaseType: projectDevice.phaseType,
connectionKind: projectDevice.connectionKind,
costGroup: projectDevice.costGroup,
category: projectDevice.category,
quantity: projectDevice.quantity,
powerPerUnit: projectDevice.powerPerUnit,
simultaneityFactor: projectDevice.simultaneityFactor,
cosPhi: projectDevice.cosPhi,
remark: projectDevice.remark,
voltageV: projectDevice.voltageV,
};
}
@@ -1,12 +1,7 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { db } from "../client.js";
import type { AppDatabase } from "../database-context.js";
import { projectDevices } from "../schema/project-devices.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type {
CreateProjectDeviceInput,
UpdateProjectDeviceInput,
} from "../../shared/validation/project-device.schemas.js";
function withTotalPower(row: typeof projectDevices.$inferSelect) {
return {
@@ -16,42 +11,16 @@ function withTotalPower(row: typeof projectDevices.$inferSelect) {
}
export class ProjectDeviceRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) {
const db = this.database;
const rows = await db.select().from(projectDevices).where(eq(projectDevices.projectId, projectId));
return rows.map(withTotalPower);
}
async create(projectId: string, input: CreateProjectDeviceInput) {
const id = crypto.randomUUID();
await db.insert(projectDevices).values({
id,
projectId,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
installedPowerPerUnitKw: input.powerPerUnit,
demandFactor: input.simultaneityFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseType === "three_phase" ? 3 : 1,
powerFactor: input.cosPhi ?? null,
note: input.remark ?? null,
});
const created = await this.findById(projectId, id);
if (!created) {
throw new Error("Failed to create project device.");
}
return created;
}
async findById(projectId: string, projectDeviceId: string) {
const db = this.database;
const [row] = await db
.select()
.from(projectDevices)
@@ -62,38 +31,4 @@ export class ProjectDeviceRepository {
return row ? withTotalPower(row) : null;
}
async update(projectId: string, projectDeviceId: string, input: UpdateProjectDeviceInput) {
await db
.update(projectDevices)
.set({
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
installedPowerPerUnitKw: input.powerPerUnit,
demandFactor: input.simultaneityFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseType === "three_phase" ? 3 : 1,
powerFactor: input.cosPhi ?? null,
note: input.remark ?? null,
})
.where(
and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId))
);
}
async delete(projectId: string, projectDeviceId: string) {
await db
.delete(projectDevices)
.where(
and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId))
);
}
}
@@ -0,0 +1,121 @@
import { and, desc, eq, max } from "drizzle-orm";
import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
export interface ApplyProjectHistoryTransitionInput {
projectId: string;
source: ProjectRevisionSource;
recordedChangeSetId: string;
targetChangeSetId?: string;
}
export function applyProjectHistoryTransition(
database: AppDatabase,
input: ApplyProjectHistoryTransitionInput
) {
if (input.source === "user" || input.source === "restore") {
database
.delete(projectHistoryStackEntries)
.where(
and(
eq(projectHistoryStackEntries.projectId, input.projectId),
eq(projectHistoryStackEntries.stack, "redo")
)
)
.run();
push(
database,
input.projectId,
input.recordedChangeSetId,
"undo"
);
return;
}
if (input.source === "undo" || input.source === "redo") {
if (!input.targetChangeSetId) {
throw new Error(
`${input.source} requires a target change set.`
);
}
const sourceStack = input.source;
const targetStack = input.source === "undo" ? "redo" : "undo";
const top = database
.select({
changeSetId: projectHistoryStackEntries.changeSetId,
})
.from(projectHistoryStackEntries)
.where(
and(
eq(projectHistoryStackEntries.projectId, input.projectId),
eq(projectHistoryStackEntries.stack, sourceStack)
)
)
.orderBy(desc(projectHistoryStackEntries.position))
.limit(1)
.get();
if (!top || top.changeSetId !== input.targetChangeSetId) {
throw new Error(
`${input.source} target is not the top ${sourceStack} command.`
);
}
const nextPosition = getNextPosition(
database,
input.projectId,
targetStack
);
const moved = database
.update(projectHistoryStackEntries)
.set({ stack: targetStack, position: nextPosition })
.where(
and(
eq(projectHistoryStackEntries.projectId, input.projectId),
eq(
projectHistoryStackEntries.changeSetId,
input.targetChangeSetId
)
)
)
.run();
if (moved.changes !== 1) {
throw new Error("Project history stack changed during transition.");
}
}
}
function push(
database: AppDatabase,
projectId: string,
changeSetId: string,
stack: "undo" | "redo"
) {
database
.insert(projectHistoryStackEntries)
.values({
projectId,
changeSetId,
stack,
position: getNextPosition(database, projectId, stack),
})
.run();
}
function getNextPosition(
database: AppDatabase,
projectId: string,
stack: "undo" | "redo"
) {
const current = database
.select({ position: max(projectHistoryStackEntries.position) })
.from(projectHistoryStackEntries)
.where(
and(
eq(projectHistoryStackEntries.projectId, projectId),
eq(projectHistoryStackEntries.stack, stack)
)
)
.get();
return (current?.position ?? 0) + 1;
}
@@ -0,0 +1,244 @@
import { and, asc, desc, eq, inArray, lt } from "drizzle-orm";
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
import type {
ProjectHistoryCommand,
ProjectHistoryDirection,
ListProjectRevisionsInput,
ProjectRevisionPage,
ProjectRevisionSummary,
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";
import { projectRevisions } from "../schema/project-revisions.js";
import { projects } from "../schema/projects.js";
export class ProjectHistoryRepository implements ProjectHistoryStore {
constructor(private readonly database: AppDatabase) {}
getState(projectId: string): ProjectHistoryState | null {
const project = this.database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return null;
}
const entries = this.database
.select()
.from(projectHistoryStackEntries)
.where(eq(projectHistoryStackEntries.projectId, projectId))
.orderBy(
asc(projectHistoryStackEntries.stack),
asc(projectHistoryStackEntries.position)
)
.all();
const undoEntries = entries.filter((entry) => entry.stack === "undo");
const redoEntries = entries.filter((entry) => entry.stack === "redo");
return {
projectId,
currentRevision: project.currentRevision,
undoDepth: undoEntries.length,
redoDepth: redoEntries.length,
undoChangeSetId: undoEntries.at(-1)?.changeSetId ?? null,
redoChangeSetId: redoEntries.at(-1)?.changeSetId ?? null,
};
}
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,
};
}
listRevisionsByNumbers(
projectId: string,
revisionNumbers: number[]
): ProjectRevisionSummary[] {
for (const revisionNumber of revisionNumbers) {
if (
!Number.isSafeInteger(revisionNumber) ||
revisionNumber < 0
) {
throw new Error(
"Project revision numbers must be non-negative integers."
);
}
}
const normalizedRevisionNumbers = [
...new Set(revisionNumbers),
].filter((revisionNumber) => revisionNumber > 0);
if (!normalizedRevisionNumbers.length) {
return [];
}
return 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(
and(
eq(projectRevisions.projectId, projectId),
inArray(
projectRevisions.revisionNumber,
normalizedRevisionNumbers
)
)
)
.orderBy(desc(projectRevisions.revisionNumber))
.all()
.map((row) => ({
...row,
source: row.source as ProjectRevisionSource,
}));
}
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
): ProjectHistoryCommand | null {
const entry = this.database
.select({
changeSetId: projectHistoryStackEntries.changeSetId,
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
inversePayloadJson: projectChangeSets.inversePayloadJson,
})
.from(projectHistoryStackEntries)
.innerJoin(
projectChangeSets,
eq(
projectChangeSets.id,
projectHistoryStackEntries.changeSetId
)
)
.innerJoin(
projectRevisions,
eq(projectRevisions.id, projectChangeSets.projectRevisionId)
)
.where(
and(
eq(projectHistoryStackEntries.projectId, projectId),
eq(projectHistoryStackEntries.stack, direction),
eq(projectRevisions.projectId, projectId)
)
)
.orderBy(desc(projectHistoryStackEntries.position))
.limit(1)
.get();
if (!entry) {
return null;
}
return {
changeSetId: entry.changeSetId,
command: deserializeProjectCommand(
direction === "undo"
? entry.inversePayloadJson
: entry.forwardPayloadJson
),
};
}
}
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."
);
}
}
@@ -0,0 +1,270 @@
import { and, eq } from "drizzle-orm";
import {
assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand,
assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand,
createProjectFloorDeleteProjectCommand,
createProjectFloorInsertProjectCommand,
createProjectRoomDeleteProjectCommand,
createProjectRoomInsertProjectCommand,
projectFloorDeleteCommandType,
projectFloorInsertCommandType,
projectRoomDeleteCommandType,
projectRoomInsertCommandType,
type ProjectFloorSnapshot,
type ProjectLocationStructureProjectCommand,
type ProjectRoomSnapshot,
} from "../../domain/models/project-location-structure-project-command.model.js";
import type {
ExecuteProjectLocationStructureCommandInput,
ProjectLocationStructureProjectCommandStore,
} from "../../domain/ports/project-location-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { consumers } from "../schema/consumers.js";
import { floors } from "../schema/floors.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ProjectLocationStructureProjectCommandRepository
implements ProjectLocationStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectLocationStructureCommandInput) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: ProjectLocationStructureProjectCommand
): ProjectLocationStructureProjectCommand {
switch (command.type) {
case projectFloorInsertCommandType:
assertProjectFloorInsertProjectCommand(command);
return this.insertFloor(
database,
projectId,
command.payload.floor
);
case projectFloorDeleteCommandType:
assertProjectFloorDeleteProjectCommand(command);
return this.deleteFloor(
database,
projectId,
command.payload.floor
);
case projectRoomInsertCommandType:
assertProjectRoomInsertProjectCommand(command);
return this.insertRoom(
database,
projectId,
command.payload.room
);
case projectRoomDeleteCommandType:
assertProjectRoomDeleteProjectCommand(command);
return this.deleteRoom(
database,
projectId,
command.payload.room
);
}
}
private insertFloor(
database: AppDatabase,
projectId: string,
floor: ProjectFloorSnapshot
) {
this.assertProjectExists(database, projectId);
if (floor.projectId !== projectId) {
throw new Error("Project-floor snapshot does not belong to project.");
}
const existing = database
.select({ id: floors.id })
.from(floors)
.where(eq(floors.id, floor.id))
.get();
if (existing) {
throw new Error("Project-floor id already exists.");
}
database.insert(floors).values(floor).run();
return createProjectFloorDeleteProjectCommand(floor);
}
private deleteFloor(
database: AppDatabase,
projectId: string,
expected: ProjectFloorSnapshot
) {
const persisted = database
.select()
.from(floors)
.where(
and(
eq(floors.id, expected.id),
eq(floors.projectId, projectId)
)
)
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project floor changed before deletion.");
}
const referencedRoom = database
.select({ id: rooms.id })
.from(rooms)
.where(eq(rooms.floorId, expected.id))
.limit(1)
.get();
if (referencedRoom) {
throw new Error(
"A project floor with assigned rooms cannot be removed by history."
);
}
const referencedDistributionBoard = database
.select({ id: distributionBoards.id })
.from(distributionBoards)
.where(eq(distributionBoards.floorId, expected.id))
.limit(1)
.get();
if (referencedDistributionBoard) {
throw new Error(
"A project floor assigned to a distribution board cannot be removed by history."
);
}
const deleted = database
.delete(floors)
.where(
and(
eq(floors.id, expected.id),
eq(floors.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Project floor could not be deleted.");
}
return createProjectFloorInsertProjectCommand(expected);
}
private insertRoom(
database: AppDatabase,
projectId: string,
room: ProjectRoomSnapshot
) {
this.assertProjectExists(database, projectId);
if (room.projectId !== projectId) {
throw new Error("Project-room snapshot does not belong to project.");
}
if (room.floorId) {
const floor = database
.select({ id: floors.id })
.from(floors)
.where(
and(
eq(floors.id, room.floorId),
eq(floors.projectId, projectId)
)
)
.get();
if (!floor) {
throw new Error("Project room references a foreign floor.");
}
}
const existing = database
.select({ id: rooms.id })
.from(rooms)
.where(eq(rooms.id, room.id))
.get();
if (existing) {
throw new Error("Project-room id already exists.");
}
database.insert(rooms).values(room).run();
return createProjectRoomDeleteProjectCommand(room);
}
private deleteRoom(
database: AppDatabase,
projectId: string,
expected: ProjectRoomSnapshot
) {
const persisted = database
.select()
.from(rooms)
.where(
and(
eq(rooms.id, expected.id),
eq(rooms.projectId, projectId)
)
)
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project room changed before deletion.");
}
const referencedDeviceRow = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.roomId, expected.id))
.limit(1)
.get();
const referencedLegacyConsumer = database
.select({ id: consumers.id })
.from(consumers)
.where(eq(consumers.roomId, expected.id))
.limit(1)
.get();
if (referencedDeviceRow || referencedLegacyConsumer) {
throw new Error(
"A referenced project room cannot be removed by history."
);
}
const deleted = database
.delete(rooms)
.where(
and(
eq(rooms.id, expected.id),
eq(rooms.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Project room could not be deleted.");
}
return createProjectRoomInsertProjectCommand(expected);
}
private assertProjectExists(
database: AppDatabase,
projectId: string
) {
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
}
}
function sameRecord(
expected: object,
actual: object
) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(([key, value]) => actualRecord[key] === value)
);
}
@@ -0,0 +1,102 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { serializeProjectCommand } from "../../domain/models/project-command.model.js";
import type {
AppendProjectRevisionInput,
AppendedProjectRevision,
} from "../../domain/ports/project-revision.store.js";
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,
input: AppendProjectRevisionInput
): AppendedProjectRevision {
validateInput(input);
const forwardPayloadJson = serializeProjectCommand(input.forward);
const inversePayloadJson = serializeProjectCommand(input.inverse);
const revisionId = crypto.randomUUID();
const changeSetId = crypto.randomUUID();
const revisionNumber = input.expectedRevision + 1;
const createdAtIso = new Date().toISOString();
const revisionUpdate = database
.update(projects)
.set({ currentRevision: revisionNumber })
.where(
and(
eq(projects.id, input.projectId),
eq(projects.currentRevision, input.expectedRevision)
)
)
.run();
if (revisionUpdate.changes !== 1) {
const current = database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
throw new ProjectRevisionConflictError(
input.projectId,
input.expectedRevision,
current?.currentRevision ?? null
);
}
database
.insert(projectRevisions)
.values({
id: revisionId,
projectId: input.projectId,
revisionNumber,
createdAtIso,
actorId: input.actorId,
source: input.source,
description: input.description,
})
.run();
database
.insert(projectChangeSets)
.values({
id: changeSetId,
projectRevisionId: revisionId,
commandType: input.forward.type,
payloadSchemaVersion: input.forward.schemaVersion,
forwardPayloadJson,
inversePayloadJson,
})
.run();
captureAutomaticProjectSnapshot(database, {
projectId: input.projectId,
currentRevision: revisionNumber,
createdAtIso,
actorId: input.actorId,
});
return {
revisionId,
changeSetId,
projectId: input.projectId,
revisionNumber,
createdAtIso,
};
}
function validateInput(input: AppendProjectRevisionInput) {
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) {
throw new Error("Expected project revision must be a non-negative integer.");
}
if (input.forward.schemaVersion !== input.inverse.schemaVersion) {
throw new Error(
"Forward and inverse project commands require the same schema version."
);
}
}
@@ -0,0 +1,166 @@
import { and, eq, isNotNull } from "drizzle-orm";
import {
assertProjectSettingsUpdateProjectCommand,
createProjectSettingsUpdateProjectCommand,
legacyProjectSettingsUpdateCommandSchemaVersion,
normalizeProjectSettingsValues,
previousProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues,
} 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 { distributionBoards } from "../schema/distribution-boards.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateAllDerivedProjectVoltages } from "./project-voltage.persistence.js";
export class ProjectSettingsProjectCommandRepository
implements ProjectSettingsProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) {
assertProjectSettingsUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteProjectSettingsUpdateCommandInput
) {
const current = tx
.select()
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!current) {
throw new Error("Project not found.");
}
const target = normalizeProjectSettingsValues(input.command, current);
if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change.");
}
const inverse =
input.command.schemaVersion ===
legacyProjectSettingsUpdateCommandSchemaVersion
? {
schemaVersion: legacyProjectSettingsUpdateCommandSchemaVersion,
type: input.command.type,
payload: {
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
},
}
: input.command.schemaVersion ===
previousProjectSettingsUpdateCommandSchemaVersion
? {
schemaVersion:
previousProjectSettingsUpdateCommandSchemaVersion,
type: input.command.type,
payload: {
name: current.name,
internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
buildingOwner: current.buildingOwner,
description: current.description,
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
},
}
: createProjectSettingsUpdateProjectCommand({
name: current.name,
internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
buildingOwner: current.buildingOwner,
description: current.description,
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes,
});
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
const updated = tx
.update(projects)
.set(target)
.where(eq(projects.id, input.projectId))
.run();
if (updated.changes !== 1) {
throw new Error("Project changed before settings update.");
}
updateAllDerivedProjectVoltages(tx, input.projectId);
return inverse;
}
}
function projectSettingsEqual(
current: ProjectSettingsValues,
target: ProjectSettingsValues
) {
return (
current.name === target.name &&
current.internalProjectNumber === target.internalProjectNumber &&
current.externalProjectNumber === target.externalProjectNumber &&
current.buildingOwner === target.buildingOwner &&
current.description === target.description &&
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV
&& sameSupplyTypes(
current.enabledDistributionBoardSupplyTypes,
target.enabledDistributionBoardSupplyTypes
)
);
}
function sameSupplyTypes(
left: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"],
right: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"]
) {
return (
left.length === right.length &&
left.every((value, index) => value === right[index])
);
}
function assertDisabledSupplyTypesAreUnused(
database: AppDatabase,
projectId: string,
target: ProjectSettingsValues
) {
const used = database
.select({ supplyType: distributionBoards.supplyType })
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
isNotNull(distributionBoards.supplyType)
)
)
.all();
const disabledUsedTypes = [
...new Set(
used
.map((entry) => entry.supplyType)
.filter(
(supplyType) =>
supplyType !== null &&
!target.enabledDistributionBoardSupplyTypes.some(
(enabled) => enabled === supplyType
)
)
),
];
if (disabledUsedTypes.length > 0) {
throw new Error(
`Verwendete Netzarten können nicht deaktiviert werden: ${disabledUsedTypes.join(", ")}.`
);
}
}
@@ -0,0 +1,206 @@
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 {
distributionBoardProjectStateSnapshotSchemaVersion,
deserializeProjectStateSnapshot,
legacyProjectStateSnapshotSchemaVersion,
previousProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion,
supplyTypesProjectStateSnapshotSchemaVersion,
voltageProjectStateSnapshotSchemaVersion,
} 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 !== legacyProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== previousProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
distributionBoardProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
supplyTypesProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
voltageProjectStateSnapshotSchemaVersion &&
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,366 @@
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 {
parseProjectStateSnapshot,
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 { projectChangeSets } from "../schema/project-change-sets.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
readProjectStateSnapshot,
} from "./project-state-snapshot.persistence.js";
export class ProjectStateRestoreCommandRepository
implements ProjectStateRestoreCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectStateRestoreCommandInput) {
assertProjectStateRestoreCommand(input.command);
const targetState = parseProjectStateSnapshot(
input.command.payload.targetState
);
if (targetState.project.id !== input.projectId) {
throw new Error("Restore state belongs to a different project.");
}
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
tx: AppDatabase,
input: ExecuteProjectStateRestoreCommandInput
) {
const current = readProjectStateSnapshot(tx, input.projectId);
if (!current) {
throw new Error("Project not found.");
}
if (
current.payloadSha256 !==
input.command.payload.expectedStateSha256 &&
!matchesHistoryRestoreTarget(tx, input, current.state)
) {
throw new ProjectStateConflictError(
input.projectId,
input.command.payload.expectedStateSha256,
current.payloadSha256
);
}
const targetState = parseProjectStateSnapshot(
input.command.payload.targetState
);
replaceProjectState(tx, targetState);
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
if (!persistedTarget) {
throw new Error("Restored project could not be loaded.");
}
const inverse = createProjectStateRestoreCommand(
persistedTarget.payloadSha256,
current.state
);
return inverse;
}
}
function matchesHistoryRestoreTarget(
database: AppDatabase,
input: ExecuteProjectStateRestoreCommandInput,
currentState: ProjectStateSnapshot
) {
if (
(input.source !== "undo" && input.source !== "redo") ||
!input.historyTargetChangeSetId
) {
return false;
}
const changeSet = database
.select({
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
inversePayloadJson: projectChangeSets.inversePayloadJson,
})
.from(projectChangeSets)
.where(eq(projectChangeSets.id, input.historyTargetChangeSetId))
.get();
if (!changeSet) {
return false;
}
try {
const serialized =
input.source === "undo"
? changeSet.forwardPayloadJson
: changeSet.inversePayloadJson;
const command = JSON.parse(serialized) as {
type?: unknown;
payload?: { targetState?: unknown };
};
if (command.type !== "project.restore-state") {
return false;
}
const expectedState = parseProjectStateSnapshot(
command.payload?.targetState
);
return (
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState)
);
} catch {
return false;
}
}
function canonicalProjectState(state: ProjectStateSnapshot) {
return JSON.stringify({
...state,
distributionBoards: byId(state.distributionBoards),
circuitLists: byId(state.circuitLists),
circuitSections: byId(state.circuitSections),
circuits: byId(
state.circuits.map((circuit) => ({
...circuit,
deviceRows: byId(circuit.deviceRows),
}))
),
projectDevices: byId(state.projectDevices),
floors: byId(state.floors),
rooms: byId(state.rooms),
});
}
function byId<T extends { id: string }>(entries: readonly T[]) {
return [...entries].sort((left, right) =>
left.id.localeCompare(right.id)
);
}
function replaceProjectState(
database: AppDatabase,
state: ProjectStateSnapshot
) {
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,
internalProjectNumber: state.project.internalProjectNumber,
externalProjectNumber: state.project.externalProjectNumber,
buildingOwner: state.project.buildingOwner,
description: state.project.description,
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
threePhaseVoltageV: state.project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
state.project.enabledDistributionBoardSupplyTypes,
})
.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,213 @@
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,
internalProjectNumber: projects.internalProjectNumber,
externalProjectNumber: projects.externalProjectNumber,
buildingOwner: projects.buildingOwner,
description: projects.description,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
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,
internalProjectNumber: project.internalProjectNumber,
externalProjectNumber: project.externalProjectNumber,
buildingOwner: project.buildingOwner,
description: project.description,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
project.enabledDistributionBoardSupplyTypes,
},
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");
}
@@ -0,0 +1,145 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import {
createProjectTransferEnvelope,
parseProjectTransferEnvelope,
remapProjectState,
} from "../../domain/models/project-transfer.model.js";
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.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 { 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";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
} from "./project-state-snapshot.persistence.js";
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
export class ProjectTransferRepository implements ProjectTransferStore {
constructor(private readonly database: AppDatabase) {}
exportProject(projectId: string) {
const snapshot = readProjectStateSnapshot(this.database, projectId);
if (!snapshot) {
return null;
}
return createProjectTransferEnvelope(
new Date().toISOString(),
snapshot.payloadSha256,
snapshot.state
);
}
prepareReplace(projectId: string, value: unknown) {
const transfer = this.parseVerified(value);
const current = readProjectStateSnapshot(this.database, projectId);
if (!current) {
return null;
}
const targetState =
transfer.projectState.project.id === projectId
? transfer.projectState
: remapProjectState(
transfer.projectState,
projectId,
() => crypto.randomUUID()
);
return {
command: createProjectStateRestoreCommand(
current.payloadSha256,
targetState
),
};
}
importDuplicate(value: unknown) {
const transfer = this.parseVerified(value);
const projectId = crypto.randomUUID();
const state = remapProjectState(
transfer.projectState,
projectId,
() => crypto.randomUUID(),
`${transfer.projectState.project.name} (Kopie)`
);
this.database.transaction((tx) => insertProjectState(tx, state));
return { projectId, name: state.project.name };
}
private parseVerified(value: unknown) {
const rawPayloadSha256 =
isPlainObject(value) &&
isPlainObject(value.projectState)
? hashProjectStatePayload(JSON.stringify(value.projectState))
: null;
const transfer = parseProjectTransferEnvelope(value);
const payloadJson = serializeProjectStateSnapshot(
transfer.projectState
);
if (
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
rawPayloadSha256 !== transfer.payloadSha256
) {
throw new Error("Project transfer checksum verification failed.");
}
return transfer;
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function insertProjectState(
database: AppDatabase,
state: ReturnType<typeof remapProjectState>
) {
const existing = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, state.project.id))
.get();
if (existing) {
throw new Error("Project transfer target already exists.");
}
database
.insert(projects)
.values({ ...state.project, currentRevision: 0 })
.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)
);
}
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
database: AppDatabase,
table: TTable,
values: unknown[]
) {
if (values.length > 0) {
database.insert(table).values(values as never).run();
}
}
@@ -0,0 +1,154 @@
import { and, eq } from "drizzle-orm";
import {
resolveCircuitPhaseType,
resolveProjectVoltage,
type ElectricalPhaseType,
type ProjectVoltageSettings,
} from "../../domain/services/project-voltage.service.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 { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
export function readProjectVoltageSettings(
database: AppDatabase,
projectId: string
): ProjectVoltageSettings {
const project = database
.select({
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
})
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
return project;
}
export function resolveProjectDeviceVoltage(
database: AppDatabase,
projectId: string,
phaseType: ElectricalPhaseType
) {
return resolveProjectVoltage(
phaseType,
readProjectVoltageSettings(database, projectId)
);
}
export function resolveCircuitVoltage(
database: AppDatabase,
projectId: string,
sectionId: string,
devicePhaseTypes: ReadonlyArray<string | null | undefined> = []
) {
const section = database
.select({ key: circuitSections.key })
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(
and(
eq(circuitSections.id, sectionId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!section) {
throw new Error("Circuit section does not belong to project.");
}
return resolveProjectVoltage(
resolveCircuitPhaseType(section.key, devicePhaseTypes),
readProjectVoltageSettings(database, projectId)
);
}
export function updateDerivedCircuitVoltage(
database: AppDatabase,
projectId: string,
circuitId: string
) {
const circuit = database
.select({
id: circuits.id,
sectionId: circuits.sectionId,
})
.from(circuits)
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(
and(
eq(circuits.id, circuitId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!circuit) {
throw new Error("Circuit does not belong to project.");
}
const devicePhaseTypes = database
.select({ phaseType: circuitDeviceRows.phaseType })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId))
.all()
.map((row) => row.phaseType);
const voltage = resolveCircuitVoltage(
database,
projectId,
circuit.sectionId,
devicePhaseTypes
);
database
.update(circuits)
.set({ voltage })
.where(eq(circuits.id, circuitId))
.run();
return voltage;
}
export function updateAllDerivedProjectVoltages(
database: AppDatabase,
projectId: string
) {
const settings = readProjectVoltageSettings(database, projectId);
const devices = database
.select({
id: projectDevices.id,
phaseType: projectDevices.phaseType,
})
.from(projectDevices)
.where(eq(projectDevices.projectId, projectId))
.all();
for (const device of devices) {
if (
device.phaseType !== "single_phase" &&
device.phaseType !== "three_phase"
) {
throw new Error("Persisted project-device phase type is invalid.");
}
database
.update(projectDevices)
.set({
voltageV: resolveProjectVoltage(device.phaseType, settings),
})
.where(eq(projectDevices.id, device.id))
.run();
}
const projectCircuits = database
.select({ id: circuits.id })
.from(circuits)
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.all();
for (const circuit of projectCircuits) {
updateDerivedCircuitVoltage(database, projectId, circuit.id);
}
}
+16 -17
View File
@@ -1,45 +1,44 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "../client.js";
import type { AppDatabase } from "../database-context.js";
import { projects } from "../schema/projects.js";
import type {
CreateProjectInput,
UpdateProjectSettingsInput,
} from "../../shared/validation/consumer.schemas.js";
} from "../../shared/validation/project-structure.schemas.js";
import { defaultDistributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
export class ProjectRepository {
constructor(private readonly database: AppDatabase) {}
async list() {
const db = this.database;
return db.select().from(projects);
}
async create(input: CreateProjectInput) {
const db = this.database;
const id = crypto.randomUUID();
const project = {
id,
name: input.name,
internalProjectNumber: input.internalProjectNumber ?? null,
externalProjectNumber: input.externalProjectNumber ?? null,
buildingOwner: input.buildingOwner ?? null,
description: input.description ?? null,
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
enabledDistributionBoardSupplyTypes: [
...defaultDistributionBoardSupplyTypes,
],
currentRevision: 0,
};
await db.insert(projects).values(project);
return project;
}
async findById(projectId: string) {
const db = this.database;
const [row] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
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));
}
}
+5 -31
View File
@@ -1,42 +1,16 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import { db } from "../client.js";
import { asc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { rooms } from "../schema/rooms.js";
import type { CreateRoomInput } from "../../shared/validation/consumer.schemas.js";
export class RoomRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) {
const db = this.database;
return db
.select()
.from(rooms)
.where(eq(rooms.projectId, projectId))
.orderBy(asc(rooms.roomNumber), asc(rooms.roomName));
}
async create(projectId: string, input: CreateRoomInput) {
const id = crypto.randomUUID();
const room = {
id,
projectId,
floorId: input.floorId ?? null,
roomNumber: input.roomNumber,
roomName: input.roomName,
};
await db.insert(rooms).values(room);
return room;
}
async existsInProject(projectId: string, roomId: string) {
const [row] = await db
.select({ id: rooms.id })
.from(rooms)
.where(and(eq(rooms.projectId, projectId), eq(rooms.id, roomId)))
.limit(1);
return Boolean(row);
}
async findById(roomId: string) {
const [row] = await db.select().from(rooms).where(eq(rooms.id, roomId)).limit(1);
return row ?? null;
}
}
+10 -1
View File
@@ -1,5 +1,7 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { floors } from "./floors.js";
import { projects } from "./projects.js";
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
export const distributionBoards = sqliteTable("distribution_boards", {
id: text("id").primaryKey(),
@@ -7,5 +9,12 @@ export const distributionBoards = sqliteTable("distribution_boards", {
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
floorId: text("floor_id").references(() => floors.id, {
onDelete: "set null",
}),
supplyType: text("supply_type").$type<DistributionBoardSupplyType>(),
simultaneityFactor: real("simultaneity_factor")
.notNull()
.default(1),
});

Some files were not shown because too many files have changed in this diff Show More