Route project devices through history
This commit is contained in:
@@ -238,7 +238,9 @@ snapshots, including link and override metadata. Canonical ProjectDevice field
|
|||||||
updates are also persisted and never synchronize linked rows implicitly.
|
updates are also persisted and never synchronize linked rows implicitly.
|
||||||
ProjectDevice insertion/deletion preserves stable device ids. Deletion captures
|
ProjectDevice insertion/deletion preserves stable device ids. Deletion captures
|
||||||
complete disconnected snapshots of linked rows so undo can restore only rows
|
complete disconnected snapshots of linked rows so undo can restore only rows
|
||||||
that have remained unchanged.
|
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.
|
||||||
|
|
||||||
Required operations:
|
Required operations:
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,27 @@ recreates the same device and reconnects only rows whose complete disconnected
|
|||||||
snapshot still matches; device, links, revision and history transition are
|
snapshot still matches; device, links, revision and history transition are
|
||||||
atomic.
|
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.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# Circuit List Editor Known Limitations
|
# Circuit List Editor Known Limitations
|
||||||
|
|
||||||
- Undo/redo history is session-local only.
|
- The circuit-list editor still exposes only its session-local undo/redo UI.
|
||||||
- Undo/redo history is not persisted across reloads or between users.
|
- Server-side project revisions and persistent undo/redo eligibility exist for
|
||||||
- No project revision or logical snapshot model is implemented yet.
|
the command types documented in `circuit-list-editor-api.md`; command coverage
|
||||||
|
and the editor UI cutover are not complete.
|
||||||
|
- Named logical project snapshots and restore are not implemented yet.
|
||||||
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
||||||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||||
- The global device library supports basic CRUD and copy operations, but has no
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
|
|||||||
@@ -115,16 +115,17 @@ noch nicht.
|
|||||||
Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem
|
Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem
|
||||||
gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel
|
gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel
|
||||||
atomar. Verknüpfte Stromkreiszeilen werden dabei bewusst nicht automatisch
|
atomar. Verknüpfte Stromkreiszeilen werden dabei bewusst nicht automatisch
|
||||||
synchronisiert. Der bestehende ProjectDevice-`PUT`-Endpunkt verwendet diesen
|
synchronisiert. Der ProjectDevice-`PUT`-Endpunkt und die Projektseite verwenden
|
||||||
Command bis zum UI-Cutover noch nicht.
|
diesen Command mit optimistischer Revisionsprüfung.
|
||||||
`project-device.insert` und `project-device.delete` versionieren außerdem den
|
`project-device.insert` und `project-device.delete` versionieren außerdem den
|
||||||
Lebenszyklus eines Projektgeräts mit stabiler UUID. Beim Löschen speichert das
|
Lebenszyklus eines Projektgeräts mit stabiler UUID. Beim Löschen speichert das
|
||||||
inverse Insert den vollständigen Gerätestand sowie vollständige, nach dem
|
inverse Insert den vollständigen Gerätestand sowie vollständige, nach dem
|
||||||
Löschen erwartete Snapshots aller zuvor verknüpften Gerätezeilen. Undo setzt
|
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,
|
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
|
unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revision
|
||||||
und Historienstapel teilen dieselbe Transaktion. Die bestehenden Create-,
|
und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
|
||||||
Import- und Delete-Endpunkte sind noch nicht umgestellt.
|
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
||||||
|
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
|
||||||
|
|
||||||
## Projektgeräte
|
## Projektgeräte
|
||||||
|
|
||||||
|
|||||||
@@ -413,15 +413,18 @@ Implemented foundation:
|
|||||||
inverses and never overwrite linked CircuitDeviceRow values implicitly
|
inverses and never overwrite linked CircuitDeviceRow values implicitly
|
||||||
- ProjectDevice insert/delete commands preserve stable device ids and restore
|
- ProjectDevice insert/delete commands preserve stable device ids and restore
|
||||||
previously linked rows only from complete unchanged disconnected snapshots
|
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
|
||||||
- revision metadata, change-set payloads and the project counter are committed
|
- revision metadata, change-set payloads and the project counter are committed
|
||||||
in one SQLite transaction
|
in one SQLite transaction
|
||||||
- a stale expected revision produces no history writes
|
- a stale expected revision produces no history writes
|
||||||
- integration coverage verifies sequential revisions, stale-command rejection
|
- integration coverage verifies sequential revisions, stale-command rejection
|
||||||
and rollback after a forced late persistence failure
|
and rollback after a forced late persistence failure
|
||||||
|
|
||||||
The current editor operations are not connected to this history boundary yet.
|
ProjectDevice CRUD on the project page is connected to this history boundary.
|
||||||
Undo/redo therefore remains session-local until the remaining tasks are
|
The circuit-list editor and the remaining domain writes are not fully connected;
|
||||||
implemented.
|
its visible undo/redo therefore remains session-local until the remaining tasks
|
||||||
|
are implemented.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ export default function ProjectDetailPage() {
|
|||||||
[circuitLists]
|
[circuitLists]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function applyProjectRevision(currentRevision: number) {
|
||||||
|
setProject((current) =>
|
||||||
|
current ? { ...current, currentRevision } : current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
|
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!projectId || !boardName.trim()) {
|
if (!projectId || !boardName.trim()) {
|
||||||
@@ -258,7 +264,7 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
async function handleCreateProjectDevice(event: FormEvent<HTMLFormElement>) {
|
async function handleCreateProjectDevice(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!projectId || !projectDeviceForm.name.trim()) {
|
if (!projectId || !project || !projectDeviceForm.name.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,8 +285,13 @@ export default function ProjectDetailPage() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const created = await createProjectDevice(projectId, payload);
|
const result = await createProjectDevice(
|
||||||
setProjectDevices((current) => [...current, created]);
|
projectId,
|
||||||
|
payload,
|
||||||
|
project.currentRevision
|
||||||
|
);
|
||||||
|
setProjectDevices((current) => [...current, result.projectDevice]);
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
setProjectDeviceForm({
|
setProjectDeviceForm({
|
||||||
name: emptyProjectDevice.name,
|
name: emptyProjectDevice.name,
|
||||||
displayName: emptyProjectDevice.displayName,
|
displayName: emptyProjectDevice.displayName,
|
||||||
@@ -302,14 +313,19 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteProjectDevice(projectDeviceId: string) {
|
async function handleDeleteProjectDevice(projectDeviceId: string) {
|
||||||
if (!projectId) {
|
if (!projectId || !project) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await deleteProjectDevice(projectId, projectDeviceId);
|
const result = await deleteProjectDevice(
|
||||||
|
projectId,
|
||||||
|
projectDeviceId,
|
||||||
|
project.currentRevision
|
||||||
|
);
|
||||||
setProjectDevices((current) => current.filter((item) => item.id !== projectDeviceId));
|
setProjectDevices((current) => current.filter((item) => item.id !== projectDeviceId));
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht gelöscht werden.");
|
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht gelöscht werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -322,7 +338,7 @@ export default function ProjectDetailPage() {
|
|||||||
key: "name" | "displayName" | "category",
|
key: "name" | "displayName" | "category",
|
||||||
value: string
|
value: string
|
||||||
) {
|
) {
|
||||||
if (!projectId) {
|
if (!projectId || !project) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,9 +358,19 @@ export default function ProjectDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await updateProjectDevice(projectId, device.id, payload);
|
const result = await updateProjectDevice(
|
||||||
setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item)));
|
projectId,
|
||||||
await openProjectDeviceSyncPreview(updated.id);
|
device.id,
|
||||||
|
payload,
|
||||||
|
project.currentRevision
|
||||||
|
);
|
||||||
|
setProjectDevices((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === device.id ? result.projectDevice : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
await openProjectDeviceSyncPreview(result.projectDevice.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
|
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
|
||||||
}
|
}
|
||||||
@@ -466,15 +492,20 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopyGlobalToProject() {
|
async function handleCopyGlobalToProject() {
|
||||||
if (!projectId || !selectedGlobalDeviceId) {
|
if (!projectId || !project || !selectedGlobalDeviceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const created = await copyGlobalDeviceToProject(projectId, selectedGlobalDeviceId);
|
const result = await copyGlobalDeviceToProject(
|
||||||
setProjectDevices((current) => [...current, created]);
|
projectId,
|
||||||
|
selectedGlobalDeviceId,
|
||||||
|
project.currentRevision
|
||||||
|
);
|
||||||
|
setProjectDevices((current) => [...current, result.projectDevice]);
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
|
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.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) {
|
function withTotalPower(row: typeof projectDevices.$inferSelect) {
|
||||||
return {
|
return {
|
||||||
@@ -21,31 +16,6 @@ export class ProjectDeviceRepository {
|
|||||||
return rows.map(withTotalPower);
|
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,
|
|
||||||
voltageV: input.voltageV ?? 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) {
|
async findById(projectId: string, projectDeviceId: string) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -57,33 +27,4 @@ export class ProjectDeviceRepository {
|
|||||||
return row ? withTotalPower(row) : null;
|
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,
|
|
||||||
voltageV: input.voltageV ?? 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))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ export interface ProjectDeviceDto {
|
|||||||
voltageV: number | null;
|
voltageV: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceCommandResultDto
|
||||||
|
extends ProjectCommandResultDto {
|
||||||
|
projectDevice: ProjectDeviceDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateFloorInput {
|
export interface CreateFloorInput {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-13
@@ -9,6 +9,7 @@ import type {
|
|||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectCommandDto,
|
ProjectCommandDto,
|
||||||
ProjectCommandResultDto,
|
ProjectCommandResultDto,
|
||||||
|
ProjectDeviceCommandResultDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
ProjectHistoryStateDto,
|
ProjectHistoryStateDto,
|
||||||
ProjectDeviceDisconnectResultDto,
|
ProjectDeviceDisconnectResultDto,
|
||||||
@@ -322,34 +323,58 @@ export function listProjectDevices(projectId: string) {
|
|||||||
return request<ProjectDeviceDto[]>(`/api/project-devices/projects/${projectId}`);
|
return request<ProjectDeviceDto[]>(`/api/project-devices/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProjectDevice(projectId: string, input: CreateProjectDeviceInput) {
|
export function createProjectDevice(
|
||||||
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}`, {
|
projectId: string,
|
||||||
|
input: CreateProjectDeviceInput,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return request<ProjectDeviceCommandResultDto>(`/api/project-devices/projects/${projectId}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify({ ...input, expectedRevision }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateProjectDevice(
|
export function updateProjectDevice(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
projectDeviceId: string,
|
projectDeviceId: string,
|
||||||
input: CreateProjectDeviceInput
|
input: CreateProjectDeviceInput,
|
||||||
|
expectedRevision: number
|
||||||
) {
|
) {
|
||||||
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
|
return request<ProjectDeviceCommandResultDto>(
|
||||||
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}`,
|
||||||
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify({ ...input, expectedRevision }),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteProjectDevice(projectId: string, projectDeviceId: string) {
|
export function deleteProjectDevice(
|
||||||
return request<void>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
|
projectId: string,
|
||||||
|
projectDeviceId: string,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return request<ProjectCommandResultDto>(
|
||||||
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}`,
|
||||||
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
body: JSON.stringify({ expectedRevision }),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: string) {
|
export function copyGlobalDeviceToProject(
|
||||||
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`, {
|
projectId: string,
|
||||||
|
globalDeviceId: string,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return request<ProjectDeviceCommandResultDto>(
|
||||||
|
`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`,
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
body: JSON.stringify({ expectedRevision }),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
|
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
import { projectDeviceSyncService } from "../composition/project-device-sync-service.js";
|
import { createProjectDeviceUpdateProjectCommand } from "../../domain/models/project-device-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
createProjectDeviceSchema,
|
createProjectDeviceDeleteProjectCommand,
|
||||||
|
createProjectDeviceInsertProjectCommand,
|
||||||
|
type ProjectDeviceSnapshot,
|
||||||
|
} from "../../domain/models/project-device-structure-project-command.model.js";
|
||||||
|
import { projectDeviceSyncService } from "../composition/project-device-sync-service.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import {
|
||||||
|
createProjectDeviceCommandSchema,
|
||||||
disconnectProjectDeviceRowsSchema,
|
disconnectProjectDeviceRowsSchema,
|
||||||
|
projectDeviceStructureCommandSchema,
|
||||||
reconnectProjectDeviceRowsSchema,
|
reconnectProjectDeviceRowsSchema,
|
||||||
restoreProjectDeviceRowsSchema,
|
restoreProjectDeviceRowsSchema,
|
||||||
synchronizeProjectDeviceRowsSchema,
|
synchronizeProjectDeviceRowsSchema,
|
||||||
updateProjectDeviceSchema,
|
updateProjectDeviceCommandSchema,
|
||||||
} from "../../shared/validation/project-device.schemas.js";
|
} from "../../shared/validation/project-device.schemas.js";
|
||||||
|
import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js";
|
||||||
|
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||||
|
|
||||||
const globalDeviceRepository = new GlobalDeviceRepository();
|
const globalDeviceRepository = new GlobalDeviceRepository();
|
||||||
const projectDeviceRepository = new ProjectDeviceRepository();
|
const projectDeviceRepository = new ProjectDeviceRepository();
|
||||||
@@ -27,12 +38,28 @@ export async function createProjectDevice(req: Request, res: Response) {
|
|||||||
if (typeof projectId !== "string") {
|
if (typeof projectId !== "string") {
|
||||||
return res.status(400).json({ error: "Invalid projectId" });
|
return res.status(400).json({ error: "Invalid projectId" });
|
||||||
}
|
}
|
||||||
const parsed = createProjectDeviceSchema.safeParse(req.body);
|
const parsed = createProjectDeviceCommandSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
const created = await projectDeviceRepository.create(projectId, parsed.data);
|
const { expectedRevision, ...input } = parsed.data;
|
||||||
return res.status(201).json(created);
|
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), input);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
description: "Projektgerät anlegen",
|
||||||
|
command: createProjectDeviceInsertProjectCommand(projectDevice),
|
||||||
|
});
|
||||||
|
const created = await projectDeviceRepository.findById(projectId, projectDevice.id);
|
||||||
|
if (!created) {
|
||||||
|
throw new Error("Created project device could not be loaded.");
|
||||||
|
}
|
||||||
|
return res.status(201).json({ ...result, projectDevice: created });
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProjectDevice(req: Request, res: Response) {
|
export async function updateProjectDevice(req: Request, res: Response) {
|
||||||
@@ -41,19 +68,32 @@ export async function updateProjectDevice(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: "Invalid parameters" });
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = updateProjectDeviceSchema.safeParse(req.body);
|
const parsed = updateProjectDeviceCommandSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
|
const { expectedRevision, ...input } = parsed.data;
|
||||||
|
|
||||||
await projectDeviceRepository.update(projectId, projectDeviceId, parsed.data);
|
try {
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
description: "Projektgerät bearbeiten",
|
||||||
|
command: createProjectDeviceUpdateProjectCommand(
|
||||||
|
projectDeviceId,
|
||||||
|
toProjectDeviceValues(input)
|
||||||
|
),
|
||||||
|
});
|
||||||
// Linked rows are synchronized only through the explicit review flow.
|
// Linked rows are synchronized only through the explicit review flow.
|
||||||
// Updating a project device must never silently overwrite local circuit-list values.
|
// Updating a project device must never silently overwrite local circuit-list values.
|
||||||
const row = await projectDeviceRepository.findById(projectId, projectDeviceId);
|
const projectDevice = await projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||||
if (!row) {
|
if (!projectDevice) {
|
||||||
return res.status(404).json({ error: "Project device not found" });
|
throw new Error("Updated project device could not be loaded.");
|
||||||
|
}
|
||||||
|
return res.json({ ...result, projectDevice });
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
}
|
}
|
||||||
return res.json(row);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProjectDevice(req: Request, res: Response) {
|
export async function deleteProjectDevice(req: Request, res: Response) {
|
||||||
@@ -62,8 +102,23 @@ export async function deleteProjectDevice(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: "Invalid parameters" });
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await projectDeviceRepository.delete(projectId, projectDeviceId);
|
const parsed = projectDeviceStructureCommandSchema.safeParse(req.body);
|
||||||
return res.status(204).send();
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return res.json(
|
||||||
|
projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Projektgerät löschen",
|
||||||
|
command: createProjectDeviceDeleteProjectCommand(projectDeviceId, projectId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
||||||
@@ -71,13 +126,17 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
|||||||
if (typeof projectId !== "string" || typeof globalDeviceId !== "string") {
|
if (typeof projectId !== "string" || typeof globalDeviceId !== "string") {
|
||||||
return res.status(400).json({ error: "Invalid parameters" });
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
}
|
}
|
||||||
|
const parsed = projectDeviceStructureCommandSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
const source = await globalDeviceRepository.findById(globalDeviceId);
|
const source = await globalDeviceRepository.findById(globalDeviceId);
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return res.status(404).json({ error: "Global device not found" });
|
return res.status(404).json({ error: "Global device not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await projectDeviceRepository.create(projectId, {
|
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), {
|
||||||
name: source.name,
|
name: source.name,
|
||||||
displayName: source.displayName,
|
displayName: source.displayName,
|
||||||
phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase",
|
phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase",
|
||||||
@@ -90,7 +149,50 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
|||||||
voltageV: source.voltageV ?? undefined,
|
voltageV: source.voltageV ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(201).json(created);
|
try {
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Globales Gerät ins Projekt übernehmen",
|
||||||
|
command: createProjectDeviceInsertProjectCommand(projectDevice),
|
||||||
|
});
|
||||||
|
const created = await projectDeviceRepository.findById(projectId, projectDevice.id);
|
||||||
|
if (!created) {
|
||||||
|
throw new Error("Imported project device could not be loaded.");
|
||||||
|
}
|
||||||
|
return res.status(201).json({ ...result, projectDevice: created });
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProjectDeviceSnapshot(
|
||||||
|
projectId: string,
|
||||||
|
id: string,
|
||||||
|
input: CreateProjectDeviceInput
|
||||||
|
): ProjectDeviceSnapshot {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
...toProjectDeviceValues(input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProjectDeviceValues(input: CreateProjectDeviceInput) {
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
voltageV: input.voltageV ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjectDeviceSyncPreview(req: Request, res: Response) {
|
export async function getProjectDeviceSyncPreview(req: Request, res: Response) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const expectedProjectRevisionSchema = z.number().int().nonnegative();
|
||||||
|
|
||||||
const projectCommandEnvelopeSchema = z.object({
|
const projectCommandEnvelopeSchema = z.object({
|
||||||
schemaVersion: z.number().int().positive(),
|
schemaVersion: z.number().int().positive(),
|
||||||
type: z.string().trim().min(1),
|
type: z.string().trim().min(1),
|
||||||
@@ -7,11 +9,11 @@ const projectCommandEnvelopeSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const executeProjectCommandSchema = z.object({
|
export const executeProjectCommandSchema = z.object({
|
||||||
expectedRevision: z.number().int().nonnegative(),
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
description: z.string().trim().min(1).max(500).optional(),
|
description: z.string().trim().min(1).max(500).optional(),
|
||||||
command: projectCommandEnvelopeSchema,
|
command: projectCommandEnvelopeSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const executeProjectHistoryCommandSchema = z.object({
|
export const executeProjectHistoryCommandSchema = z.object({
|
||||||
expectedRevision: z.number().int().nonnegative(),
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
|
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
|
||||||
|
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||||
|
|
||||||
export const createProjectDeviceSchema = z.object({
|
export const createProjectDeviceSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -19,6 +20,20 @@ export const createProjectDeviceSchema = z.object({
|
|||||||
|
|
||||||
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
||||||
|
|
||||||
|
export const createProjectDeviceCommandSchema =
|
||||||
|
createProjectDeviceSchema.extend({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateProjectDeviceCommandSchema =
|
||||||
|
updateProjectDeviceSchema.extend({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectDeviceStructureCommandSchema = z.object({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export const synchronizeProjectDeviceRowsSchema = z.object({
|
export const synchronizeProjectDeviceRowsSchema = z.object({
|
||||||
rowIds: z.array(z.string().min(1)).min(1),
|
rowIds: z.array(z.string().min(1)).min(1),
|
||||||
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
|
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { createProjectDeviceSchema } from "../src/shared/validation/project-device.schemas.js";
|
import {
|
||||||
|
createProjectDeviceCommandSchema,
|
||||||
|
createProjectDeviceSchema,
|
||||||
|
projectDeviceStructureCommandSchema,
|
||||||
|
updateProjectDeviceCommandSchema,
|
||||||
|
} from "../src/shared/validation/project-device.schemas.js";
|
||||||
|
|
||||||
describe("project device circuit-first schema", () => {
|
describe("project device circuit-first schema", () => {
|
||||||
it("accepts circuit device fields", () => {
|
it("accepts circuit device fields", () => {
|
||||||
@@ -33,4 +38,53 @@ describe("project device circuit-first schema", () => {
|
|||||||
|
|
||||||
assert.equal(result.success, false);
|
assert.equal(result.success, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires a project revision for create and update commands", () => {
|
||||||
|
const device = {
|
||||||
|
name: "E-Line Pro",
|
||||||
|
displayName: "Bürobeleuchtung",
|
||||||
|
phaseType: "single_phase" as const,
|
||||||
|
quantity: 6,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(createProjectDeviceCommandSchema.safeParse(device).success, false);
|
||||||
|
assert.equal(updateProjectDeviceCommandSchema.safeParse(device).success, false);
|
||||||
|
assert.equal(
|
||||||
|
createProjectDeviceCommandSchema.safeParse({
|
||||||
|
...device,
|
||||||
|
expectedRevision: 4,
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
updateProjectDeviceCommandSchema.safeParse({
|
||||||
|
...device,
|
||||||
|
expectedRevision: 4,
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts only non-negative integer revisions for structure commands", () => {
|
||||||
|
assert.equal(
|
||||||
|
projectDeviceStructureCommandSchema.safeParse({
|
||||||
|
expectedRevision: 0,
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
projectDeviceStructureCommandSchema.safeParse({
|
||||||
|
expectedRevision: -1,
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
projectDeviceStructureCommandSchema.safeParse({
|
||||||
|
expectedRevision: 1.5,
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user