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
+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),