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. 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:
+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 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
+5 -4
View File
@@ -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
+6 -3
View File
@@ -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:
+43 -12
View File
@@ -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))
);
}
} }
+5
View File
@@ -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
View File
@@ -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),
+55 -1
View File
@@ -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
);
});
}); });