From e930cb75b8d2251448ea428e599aca31c60df15d Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 24 Jul 2026 10:57:50 +0200 Subject: [PATCH] Route project devices through history --- AGENTS.md | 4 +- docs/circuit-list-editor-api.md | 21 +++ docs/circuit-list-editor-known-limitations.md | 8 +- docs/current-architecture.md | 9 +- docs/spec/07-implementation-phases-todo.md | 9 +- src/app/projects/[projectId]/page.tsx | 55 +++++-- .../repositories/project-device.repository.ts | 59 -------- src/frontend/types.ts | 5 + src/frontend/utils/api.ts | 57 ++++++-- .../controllers/project-device.controller.ts | 138 +++++++++++++++--- .../validation/project-command.schemas.ts | 6 +- .../validation/project-device.schemas.ts | 15 ++ tests/project-device-schema.test.ts | 56 ++++++- 13 files changed, 323 insertions(+), 119 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9afafe5..9ad7943 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index bf8f146..3efc4fa 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -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 diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 00ab566..8e5369c 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -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 diff --git a/docs/current-architecture.md b/docs/current-architecture.md index fed6f16..18fc1b6 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -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 diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 724b015..7d9d93b 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -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: diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 9f35996..ee0afe6 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -176,6 +176,12 @@ export default function ProjectDetailPage() { [circuitLists] ); + function applyProjectRevision(currentRevision: number) { + setProject((current) => + current ? { ...current, currentRevision } : current + ); + } + async function handleCreateBoard(event: FormEvent) { event.preventDefault(); if (!projectId || !boardName.trim()) { @@ -258,7 +264,7 @@ export default function ProjectDetailPage() { async function handleCreateProjectDevice(event: FormEvent) { 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 { diff --git a/src/db/repositories/project-device.repository.ts b/src/db/repositories/project-device.repository.ts index cb557f8..310aeb2 100644 --- a/src/db/repositories/project-device.repository.ts +++ b/src/db/repositories/project-device.repository.ts @@ -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)) - ); - } } diff --git a/src/frontend/types.ts b/src/frontend/types.ts index b197b21..e4d1112 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -92,6 +92,11 @@ export interface ProjectDeviceDto { voltageV: number | null; } +export interface ProjectDeviceCommandResultDto + extends ProjectCommandResultDto { + projectDevice: ProjectDeviceDto; +} + export interface CreateFloorInput { name: string; } diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 777180c..d489877 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -9,6 +9,7 @@ import type { GlobalDeviceDto, ProjectCommandDto, ProjectCommandResultDto, + ProjectDeviceCommandResultDto, ProjectDeviceDto, ProjectHistoryStateDto, ProjectDeviceDisconnectResultDto, @@ -322,34 +323,58 @@ export function listProjectDevices(projectId: string) { return request(`/api/project-devices/projects/${projectId}`); } -export function createProjectDevice(projectId: string, input: CreateProjectDeviceInput) { - return request(`/api/project-devices/projects/${projectId}`, { +export function createProjectDevice( + projectId: string, + input: CreateProjectDeviceInput, + expectedRevision: number +) { + return request(`/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(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, { - method: "PUT", - body: JSON.stringify(input), - }); + return request( + `/api/project-devices/projects/${projectId}/${projectDeviceId}`, + { + method: "PUT", + body: JSON.stringify({ ...input, expectedRevision }), + } + ); } -export function deleteProjectDevice(projectId: string, projectDeviceId: string) { - return request(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, { - method: "DELETE", - }); +export function deleteProjectDevice( + projectId: string, + projectDeviceId: string, + expectedRevision: number +) { + return request( + `/api/project-devices/projects/${projectId}/${projectDeviceId}`, + { + method: "DELETE", + body: JSON.stringify({ expectedRevision }), + } + ); } -export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: string) { - return request(`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`, { - method: "POST", - }); +export function copyGlobalDeviceToProject( + projectId: string, + globalDeviceId: string, + expectedRevision: number +) { + return request( + `/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`, + { + method: "POST", + body: JSON.stringify({ expectedRevision }), + } + ); } export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) { diff --git a/src/server/controllers/project-device.controller.ts b/src/server/controllers/project-device.controller.ts index 5a6b054..297b578 100644 --- a/src/server/controllers/project-device.controller.ts +++ b/src/server/controllers/project-device.controller.ts @@ -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) { diff --git a/src/shared/validation/project-command.schemas.ts b/src/shared/validation/project-command.schemas.ts index e371d19..9af3b43 100644 --- a/src/shared/validation/project-command.schemas.ts +++ b/src/shared/validation/project-command.schemas.ts @@ -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, }); diff --git a/src/shared/validation/project-device.schemas.ts b/src/shared/validation/project-device.schemas.ts index 941bd71..f9836b9 100644 --- a/src/shared/validation/project-device.schemas.ts +++ b/src/shared/validation/project-device.schemas.ts @@ -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), diff --git a/tests/project-device-schema.test.ts b/tests/project-device-schema.test.ts index b47eac0..1080fb4 100644 --- a/tests/project-device-schema.test.ts +++ b/tests/project-device-schema.test.ts @@ -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 + ); + }); });