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.
|
||||
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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,11 @@ export interface ProjectDeviceDto {
|
||||
voltageV: number | null;
|
||||
}
|
||||
|
||||
export interface ProjectDeviceCommandResultDto
|
||||
extends ProjectCommandResultDto {
|
||||
projectDevice: ProjectDeviceDto;
|
||||
}
|
||||
|
||||
export interface CreateFloorInput {
|
||||
name: string;
|
||||
}
|
||||
|
||||
+41
-16
@@ -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),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user