Route project devices through history
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user