Route project devices through history

This commit is contained in:
2026-07-24 10:57:50 +02:00
parent ac16cca77a
commit e930cb75b8
13 changed files with 323 additions and 119 deletions
+3 -1
View File
@@ -238,7 +238,9 @@ 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.
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:
+21
View File
@@ -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
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:
```json
@@ -1,8 +1,10 @@
# Circuit List Editor Known Limitations
- Undo/redo history is session-local only.
- Undo/redo history is not persisted across reloads or between users.
- No project revision or logical snapshot model is implemented yet.
- The circuit-list editor still exposes only its session-local undo/redo UI.
- Server-side project revisions and persistent undo/redo eligibility exist for
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.
- 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
+5 -4
View File
@@ -115,16 +115,17 @@ noch nicht.
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 bestehende ProjectDevice-`PUT`-Endpunkt verwendet diesen
Command bis zum UI-Cutover noch nicht.
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. Die bestehenden Create-,
Import- und Delete-Endpunkte sind noch nicht umgestellt.
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.
## Projektgeräte
+6 -3
View File
@@ -413,15 +413,18 @@ Implemented foundation:
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
- 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
The current editor operations are not connected to this history boundary yet.
Undo/redo therefore remains session-local until the remaining tasks are
implemented.
ProjectDevice CRUD on the project page is connected to this history boundary.
The circuit-list editor and the remaining domain writes are not fully connected;
its visible undo/redo therefore remains session-local until the remaining tasks
are implemented.
Acceptance criteria:
+43 -12
View File
@@ -176,6 +176,12 @@ export default function ProjectDetailPage() {
[circuitLists]
);
function applyProjectRevision(currentRevision: number) {
setProject((current) =>
current ? { ...current, currentRevision } : current
);
}
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !boardName.trim()) {
@@ -258,7 +264,7 @@ export default function ProjectDetailPage() {
async function handleCreateProjectDevice(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !projectDeviceForm.name.trim()) {
if (!projectId || !project || !projectDeviceForm.name.trim()) {
return;
}
@@ -279,8 +285,13 @@ export default function ProjectDetailPage() {
setIsSaving(true);
setError(null);
try {
const created = await createProjectDevice(projectId, payload);
setProjectDevices((current) => [...current, created]);
const result = await createProjectDevice(
projectId,
payload,
project.currentRevision
);
setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision);
setProjectDeviceForm({
name: emptyProjectDevice.name,
displayName: emptyProjectDevice.displayName,
@@ -302,14 +313,19 @@ export default function ProjectDetailPage() {
}
async function handleDeleteProjectDevice(projectDeviceId: string) {
if (!projectId) {
if (!projectId || !project) {
return;
}
setIsSaving(true);
setError(null);
try {
await deleteProjectDevice(projectId, projectDeviceId);
const result = await deleteProjectDevice(
projectId,
projectDeviceId,
project.currentRevision
);
setProjectDevices((current) => current.filter((item) => item.id !== projectDeviceId));
applyProjectRevision(result.history.currentRevision);
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht gelöscht werden.");
} finally {
@@ -322,7 +338,7 @@ export default function ProjectDetailPage() {
key: "name" | "displayName" | "category",
value: string
) {
if (!projectId) {
if (!projectId || !project) {
return;
}
@@ -342,9 +358,19 @@ export default function ProjectDetailPage() {
};
try {
const updated = await updateProjectDevice(projectId, device.id, payload);
setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item)));
await openProjectDeviceSyncPreview(updated.id);
const result = await updateProjectDevice(
projectId,
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) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
}
@@ -466,15 +492,20 @@ export default function ProjectDetailPage() {
}
async function handleCopyGlobalToProject() {
if (!projectId || !selectedGlobalDeviceId) {
if (!projectId || !project || !selectedGlobalDeviceId) {
return;
}
setIsSaving(true);
setError(null);
try {
const created = await copyGlobalDeviceToProject(projectId, selectedGlobalDeviceId);
setProjectDevices((current) => [...current, created]);
const result = await copyGlobalDeviceToProject(
projectId,
selectedGlobalDeviceId,
project.currentRevision
);
setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision);
} catch (err) {
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
} finally {
@@ -1,12 +1,7 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { db } from "../client.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 {
@@ -21,31 +16,6 @@ export class ProjectDeviceRepository {
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) {
const [row] = await db
.select()
@@ -57,33 +27,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,
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))
);
}
}
+5
View File
@@ -92,6 +92,11 @@ export interface ProjectDeviceDto {
voltageV: number | null;
}
export interface ProjectDeviceCommandResultDto
extends ProjectCommandResultDto {
projectDevice: ProjectDeviceDto;
}
export interface CreateFloorInput {
name: string;
}
+41 -16
View File
@@ -9,6 +9,7 @@ import type {
GlobalDeviceDto,
ProjectCommandDto,
ProjectCommandResultDto,
ProjectDeviceCommandResultDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
ProjectDeviceDisconnectResultDto,
@@ -322,34 +323,58 @@ export function listProjectDevices(projectId: string) {
return request<ProjectDeviceDto[]>(`/api/project-devices/projects/${projectId}`);
}
export function createProjectDevice(projectId: string, input: CreateProjectDeviceInput) {
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}`, {
export function createProjectDevice(
projectId: string,
input: CreateProjectDeviceInput,
expectedRevision: number
) {
return request<ProjectDeviceCommandResultDto>(`/api/project-devices/projects/${projectId}`, {
method: "POST",
body: JSON.stringify(input),
body: JSON.stringify({ ...input, expectedRevision }),
});
}
export function updateProjectDevice(
projectId: string,
projectDeviceId: string,
input: CreateProjectDeviceInput
input: CreateProjectDeviceInput,
expectedRevision: number
) {
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
method: "PUT",
body: JSON.stringify(input),
});
return request<ProjectDeviceCommandResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}`,
{
method: "PUT",
body: JSON.stringify({ ...input, expectedRevision }),
}
);
}
export function deleteProjectDevice(projectId: string, projectDeviceId: string) {
return request<void>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
method: "DELETE",
});
export function deleteProjectDevice(
projectId: string,
projectDeviceId: string,
expectedRevision: number
) {
return request<ProjectCommandResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}`,
{
method: "DELETE",
body: JSON.stringify({ expectedRevision }),
}
);
}
export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: string) {
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`, {
method: "POST",
});
export function copyGlobalDeviceToProject(
projectId: string,
globalDeviceId: string,
expectedRevision: number
) {
return request<ProjectDeviceCommandResultDto>(
`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`,
{
method: "POST",
body: JSON.stringify({ expectedRevision }),
}
);
}
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
@@ -1,15 +1,26 @@
import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import { GlobalDeviceRepository } from "../../db/repositories/global-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 {
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,
projectDeviceStructureCommandSchema,
reconnectProjectDeviceRowsSchema,
restoreProjectDeviceRowsSchema,
synchronizeProjectDeviceRowsSchema,
updateProjectDeviceSchema,
updateProjectDeviceCommandSchema,
} 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 projectDeviceRepository = new ProjectDeviceRepository();
@@ -27,12 +38,28 @@ export async function createProjectDevice(req: Request, res: Response) {
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = createProjectDeviceSchema.safeParse(req.body);
const parsed = createProjectDeviceCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const created = await projectDeviceRepository.create(projectId, parsed.data);
return res.status(201).json(created);
const { expectedRevision, ...input } = parsed.data;
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) {
@@ -41,19 +68,32 @@ export async function updateProjectDevice(req: Request, res: Response) {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = updateProjectDeviceSchema.safeParse(req.body);
const parsed = updateProjectDeviceCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { expectedRevision, ...input } = parsed.data;
await projectDeviceRepository.update(projectId, projectDeviceId, parsed.data);
// Linked rows are synchronized only through the explicit review flow.
// Updating a project device must never silently overwrite local circuit-list values.
const row = await projectDeviceRepository.findById(projectId, projectDeviceId);
if (!row) {
return res.status(404).json({ error: "Project device not found" });
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.
// Updating a project device must never silently overwrite local circuit-list values.
const projectDevice = await projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) {
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) {
@@ -62,8 +102,23 @@ export async function deleteProjectDevice(req: Request, res: Response) {
return res.status(400).json({ error: "Invalid parameters" });
}
await projectDeviceRepository.delete(projectId, projectDeviceId);
return res.status(204).send();
const parsed = projectDeviceStructureCommandSchema.safeParse(req.body);
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) {
@@ -71,13 +126,17 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
if (typeof projectId !== "string" || typeof globalDeviceId !== "string") {
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);
if (!source) {
return res.status(404).json({ error: "Global device not found" });
}
const created = await projectDeviceRepository.create(projectId, {
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), {
name: source.name,
displayName: source.displayName,
phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase",
@@ -90,7 +149,50 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
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) {
@@ -1,5 +1,7 @@
import { z } from "zod";
export const expectedProjectRevisionSchema = z.number().int().nonnegative();
const projectCommandEnvelopeSchema = z.object({
schemaVersion: z.number().int().positive(),
type: z.string().trim().min(1),
@@ -7,11 +9,11 @@ const projectCommandEnvelopeSchema = z.object({
});
export const executeProjectCommandSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
expectedRevision: expectedProjectRevisionSchema,
description: z.string().trim().min(1).max(500).optional(),
command: projectCommandEnvelopeSchema,
});
export const executeProjectHistoryCommandSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
expectedRevision: expectedProjectRevisionSchema,
});
@@ -1,5 +1,6 @@
import { z } from "zod";
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createProjectDeviceSchema = z.object({
name: z.string().min(1),
@@ -19,6 +20,20 @@ export const createProjectDeviceSchema = z.object({
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({
rowIds: z.array(z.string().min(1)).min(1),
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
+55 -1
View File
@@ -1,6 +1,11 @@
import assert from "node:assert/strict";
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", () => {
it("accepts circuit device fields", () => {
@@ -33,4 +38,53 @@ describe("project device circuit-first schema", () => {
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
);
});
});