Make device sync atomic and undoable

This commit is contained in:
2026-07-22 19:47:10 +02:00
parent 6f8b292b6b
commit b2f5ce77a5
11 changed files with 481 additions and 65 deletions
+4
View File
@@ -156,8 +156,12 @@ These remain for migration/legacy views. New circuit-list interactions must use
- returns all linked circuit device rows with distribution-board/circuit context and field differences - returns all linked circuit device rows with distribution-board/circuit context and field differences
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize` - `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
- applies only explicitly selected fields to explicitly selected linked rows - applies only explicitly selected fields to explicitly selected linked rows
- `POST /project-devices/projects/:projectId/:projectDeviceId/restore`
- restores the captured pre-sync values for session-local undo
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect` - `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
- disconnects explicitly selected rows without changing their local values - disconnects explicitly selected rows without changing their local values
- `POST /project-devices/projects/:projectId/:projectDeviceId/reconnect`
- restores a disconnected link if the row has not been linked elsewhere meanwhile
`displayName` is included in the comparison but is not selected by default in the UI. Updating a `displayName` is included in the comparison but is not selected by default in the UI. Updating a
project device never triggers synchronization implicitly. project device never triggers synchronization implicitly.
@@ -9,5 +9,3 @@
- Sorting is view-only until users explicitly apply sorted order. - Sorting is view-only until users explicitly apply sorted order.
- Cross-section circuit drag-reorder is intentionally blocked. - Cross-section circuit drag-reorder is intentionally blocked.
- Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline. - Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline.
- Project-device synchronization and disconnect actions are explicit, but are not yet connected to an undo command.
- Multi-row project-device synchronization is sequential and not yet wrapped in one database transaction.
+68 -4
View File
@@ -20,6 +20,8 @@ import {
listProjectDevices, listProjectDevices,
listProjects, listProjects,
listRooms, listRooms,
reconnectProjectDeviceRows,
restoreProjectDeviceRows,
synchronizeProjectDeviceRows, synchronizeProjectDeviceRows,
updateProjectDevice, updateProjectDevice,
updateProjectSettings, updateProjectSettings,
@@ -32,6 +34,7 @@ import type {
GlobalDeviceDto, GlobalDeviceDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectDeviceSyncPreviewDto, ProjectDeviceSyncPreviewDto,
ProjectDeviceSyncRestoreRowDto,
ProjectDto, ProjectDto,
RoomDto, RoomDto,
} from "../../../frontend/types"; } from "../../../frontend/types";
@@ -54,6 +57,10 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
remark: "Bemerkung", remark: "Bemerkung",
}; };
type ProjectDeviceUndoState =
| { kind: "synchronize"; projectDeviceId: string; rows: ProjectDeviceSyncRestoreRowDto[] }
| { kind: "disconnect"; projectDeviceId: string; rowIds: string[] };
const emptyProjectDevice: CreateProjectDeviceInput = { const emptyProjectDevice: CreateProjectDeviceInput = {
name: "", name: "",
displayName: "", displayName: "",
@@ -110,6 +117,7 @@ export default function ProjectDetailPage() {
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null); const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]); const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]); const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
const [projectDeviceUndo, setProjectDeviceUndo] = useState<ProjectDeviceUndoState | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -347,6 +355,7 @@ export default function ProjectDetailPage() {
return; return;
} }
try { try {
setProjectDeviceUndo(null);
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId); const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
setSyncPreview(preview); setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)); setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
@@ -380,14 +389,21 @@ export default function ProjectDetailPage() {
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const preview = await synchronizeProjectDeviceRows( const result = await synchronizeProjectDeviceRows(
projectId, projectId,
syncPreview.projectDevice.id, syncPreview.projectDevice.id,
selectedSyncRowIds, selectedSyncRowIds,
selectedSyncFields selectedSyncFields
); );
setSyncPreview(preview); setSyncPreview(result.preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)); setSelectedSyncRowIds(
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
);
setProjectDeviceUndo({
kind: "synchronize",
projectDeviceId: syncPreview.projectDevice.id,
rows: result.undo.rows,
});
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden."); setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
} finally { } finally {
@@ -402,8 +418,17 @@ export default function ProjectDetailPage() {
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
await disconnectProjectDeviceRows(projectId, syncPreview.projectDevice.id, selectedSyncRowIds); const result = await disconnectProjectDeviceRows(
projectId,
syncPreview.projectDevice.id,
selectedSyncRowIds
);
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id); await openProjectDeviceSyncPreview(syncPreview.projectDevice.id);
setProjectDeviceUndo({
kind: "disconnect",
projectDeviceId: syncPreview.projectDevice.id,
rowIds: result.undo.rowIds,
});
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden."); setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
} finally { } finally {
@@ -411,6 +436,35 @@ export default function ProjectDetailPage() {
} }
} }
async function handleUndoProjectDeviceOperation() {
if (!projectId || !projectDeviceUndo) {
return;
}
setIsSaving(true);
setError(null);
try {
const preview =
projectDeviceUndo.kind === "synchronize"
? await restoreProjectDeviceRows(
projectId,
projectDeviceUndo.projectDeviceId,
projectDeviceUndo.rows
)
: await reconnectProjectDeviceRows(
projectId,
projectDeviceUndo.projectDeviceId,
projectDeviceUndo.rowIds
);
setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
setProjectDeviceUndo(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Die letzte Aktion konnte nicht rückgängig gemacht werden.");
} finally {
setIsSaving(false);
}
}
async function handleCopyGlobalToProject() { async function handleCopyGlobalToProject() {
if (!projectId || !selectedGlobalDeviceId) { if (!projectId || !selectedGlobalDeviceId) {
return; return;
@@ -1039,6 +1093,16 @@ export default function ProjectDetailPage() {
</div> </div>
<div className="d-flex flex-wrap gap-2"> <div className="d-flex flex-wrap gap-2">
{projectDeviceUndo ? (
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => void handleUndoProjectDeviceOperation()}
disabled={isSaving}
>
Letzte Aktion rückgängig
</button>
) : null}
<button <button
className="btn btn-primary" className="btn btn-primary"
type="button" type="button"
@@ -1,11 +1,53 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { and, asc, eq, inArray } from "drizzle-orm"; import { and, asc, eq, inArray, isNull } from "drizzle-orm";
import { db } from "../client.js"; import { db } from "../client.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js"; import { distributionBoards } from "../schema/distribution-boards.js";
export interface CircuitDeviceRowUpdateInput {
linkedProjectDeviceId?: string;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}
function toUpdateValues(input: CircuitDeviceRowUpdateInput) {
return {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType ?? null,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
level: input.level ?? null,
roomId: input.roomId ?? null,
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
roomNameSnapshot: input.roomNameSnapshot ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
overriddenFields: input.overriddenFields ?? null,
};
}
export class CircuitDeviceRowRepository { export class CircuitDeviceRowRepository {
async findById(rowId: string) { async findById(rowId: string) {
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1); const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
@@ -72,6 +114,21 @@ export class CircuitDeviceRowRepository {
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder)); .orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
} }
async listLinkStatesByIds(projectId: string, rowIds: string[]) {
if (rowIds.length === 0) {
return [];
}
return db
.select({
id: circuitDeviceRows.id,
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
.where(and(eq(circuitLists.projectId, projectId), inArray(circuitDeviceRows.id, rowIds)));
}
async create(input: { async create(input: {
circuitId: string; circuitId: string;
linkedProjectDeviceId?: string; linkedProjectDeviceId?: string;
@@ -123,50 +180,72 @@ export class CircuitDeviceRowRepository {
async update( async update(
rowId: string, rowId: string,
input: { input: CircuitDeviceRowUpdateInput
linkedProjectDeviceId?: string;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}
) { ) {
await db await db
.update(circuitDeviceRows) .update(circuitDeviceRows)
.set({ .set(toUpdateValues(input))
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType ?? null,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
level: input.level ?? null,
roomId: input.roomId ?? null,
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
roomNameSnapshot: input.roomNameSnapshot ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
overriddenFields: input.overriddenFields ?? null,
})
.where(eq(circuitDeviceRows.id, rowId)); .where(eq(circuitDeviceRows.id, rowId));
} }
updateLinkedRowsTransactional(
projectDeviceId: string,
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
) {
db.transaction((tx) => {
for (const change of changes) {
const result = tx
.update(circuitDeviceRows)
.set(toUpdateValues(change.input))
.where(
and(
eq(circuitDeviceRows.id, change.rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("A linked row changed before the operation could be completed.");
}
}
});
}
disconnectLinkedRowsTransactional(projectDeviceId: string, rowIds: string[]) {
db.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: null })
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("A linked row changed before the operation could be completed.");
}
}
});
}
reconnectRowsTransactional(projectDeviceId: string, rowIds: string[]) {
db.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: projectDeviceId })
.where(and(eq(circuitDeviceRows.id, rowId), isNull(circuitDeviceRows.linkedProjectDeviceId)))
.run();
if (result.changes !== 1) {
throw new Error("A disconnected row changed before the operation could be undone.");
}
}
});
}
async delete(rowId: string) { async delete(rowId: string) {
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)); await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
} }
@@ -10,9 +10,22 @@ type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProj
type SyncDependencies = { type SyncDependencies = {
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">; projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice" | "update">; deviceRowRepository: Pick<
CircuitDeviceRowRepository,
| "listLinkedByProjectDevice"
| "listLinkStatesByIds"
| "updateLinkedRowsTransactional"
| "disconnectLinkedRowsTransactional"
| "reconnectRowsTransactional"
>;
}; };
export interface ProjectDeviceSyncRestoreRow {
rowId: string;
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
overriddenFields: ProjectDeviceSyncField[];
}
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] { export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
if (!value) { if (!value) {
return []; return [];
@@ -106,7 +119,13 @@ export class ProjectDeviceSyncService {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId); const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds); const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const selectedFields = [...new Set(fields)]; const selectedFields = [...new Set(fields)];
const undoRows: ProjectDeviceSyncRestoreRow[] = selectedRows.map((row) => ({
rowId: row.id,
values: Object.fromEntries(selectedFields.map((field) => [field, row[field] ?? null])),
overriddenFields: parseOverriddenFields(row.overriddenFields),
}));
const changes = [];
for (const row of selectedRows) { for (const row of selectedRows) {
const next = this.copyRow(row); const next = this.copyRow(row);
for (const field of selectedFields) { for (const field of selectedFields) {
@@ -116,24 +135,62 @@ export class ProjectDeviceSyncService {
(field) => !selectedFields.includes(field) (field) => !selectedFields.includes(field)
); );
next.overriddenFields = serializeOverriddenFields(remainingOverrides); next.overriddenFields = serializeOverriddenFields(remainingOverrides);
await this.deviceRowRepository.update(row.id, next); changes.push({ rowId: row.id, input: next });
} }
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes);
return this.getPreview(projectId, projectDeviceId); return {
preview: await this.getPreview(projectId, projectDeviceId),
undo: { rows: undoRows },
};
} }
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) { async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId); const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds); const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const disconnectedRowIds = selectedRows.map((row) => row.id);
for (const row of selectedRows) { this.deviceRowRepository.disconnectLinkedRowsTransactional(projectDeviceId, disconnectedRowIds);
await this.deviceRowRepository.update(row.id, {
...this.copyRow(row), return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } };
linkedProjectDeviceId: undefined,
});
} }
return { disconnectedRowIds: selectedRows.map((row) => row.id) }; async restore(
projectId: string,
projectDeviceId: string,
restoreRows: ProjectDeviceSyncRestoreRow[]
) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, restoreRows.map((row) => row.rowId));
const restoreById = new Map(restoreRows.map((row) => [row.rowId, row]));
const changes = selectedRows.map((row) => {
const restore = restoreById.get(row.id)!;
const next = this.copyRow(row);
for (const [field, value] of Object.entries(restore.values) as Array<
[ProjectDeviceSyncField, string | number | null]
>) {
Object.assign(next, { [field]: value ?? undefined });
}
next.overriddenFields = serializeOverriddenFields(restore.overriddenFields);
return { rowId: row.id, input: next };
});
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes);
return this.getPreview(projectId, projectDeviceId);
}
async reconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) {
throw new Error("Project device not found.");
}
const uniqueRowIds = [...new Set(rowIds)];
const linkStates = await this.deviceRowRepository.listLinkStatesByIds(projectId, uniqueRowIds);
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) {
throw new Error("One or more rows cannot be reconnected.");
}
this.deviceRowRepository.reconnectRowsTransactional(projectDeviceId, uniqueRowIds);
return this.getPreview(projectId, projectDeviceId);
} }
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) { private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
+20
View File
@@ -210,6 +210,26 @@ export interface ProjectDeviceSyncPreviewDto {
rows: ProjectDeviceSyncRowDto[]; rows: ProjectDeviceSyncRowDto[];
} }
export interface ProjectDeviceSyncRestoreRowDto {
rowId: string;
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
overriddenFields: ProjectDeviceSyncField[];
}
export interface ProjectDeviceSyncResultDto {
preview: ProjectDeviceSyncPreviewDto;
undo: {
rows: ProjectDeviceSyncRestoreRowDto[];
};
}
export interface ProjectDeviceDisconnectResultDto {
disconnectedRowIds: string[];
undo: {
rowIds: string[];
};
}
export interface CircuitTreeDeviceRowDto { export interface CircuitTreeDeviceRowDto {
id: string; id: string;
linkedProjectDeviceId?: string; linkedProjectDeviceId?: string;
+33 -2
View File
@@ -10,7 +10,10 @@ import type {
FloorDto, FloorDto,
GlobalDeviceDto, GlobalDeviceDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectDeviceDisconnectResultDto,
ProjectDeviceSyncPreviewDto, ProjectDeviceSyncPreviewDto,
ProjectDeviceSyncRestoreRowDto,
ProjectDeviceSyncResultDto,
ProjectDto, ProjectDto,
RoomDto, RoomDto,
UpdateConsumerInput, UpdateConsumerInput,
@@ -300,7 +303,7 @@ export function synchronizeProjectDeviceRows(
rowIds: string[], rowIds: string[],
fields: ProjectDeviceSyncField[] fields: ProjectDeviceSyncField[]
) { ) {
return request<ProjectDeviceSyncPreviewDto>( return request<ProjectDeviceSyncResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`, `/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
{ {
method: "POST", method: "POST",
@@ -314,7 +317,7 @@ export function disconnectProjectDeviceRows(
projectDeviceId: string, projectDeviceId: string,
rowIds: string[] rowIds: string[]
) { ) {
return request<{ disconnectedRowIds: string[] }>( return request<ProjectDeviceDisconnectResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`, `/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
{ {
method: "POST", method: "POST",
@@ -322,3 +325,31 @@ export function disconnectProjectDeviceRows(
} }
); );
} }
export function restoreProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rows: ProjectDeviceSyncRestoreRowDto[]
) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/restore`,
{
method: "POST",
body: JSON.stringify({ rows }),
}
);
}
export function reconnectProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rowIds: string[]
) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/reconnect`,
{
method: "POST",
body: JSON.stringify({ rowIds }),
}
);
}
@@ -5,6 +5,8 @@ import { ProjectDeviceSyncService } from "../../domain/services/project-device-s
import { import {
createProjectDeviceSchema, createProjectDeviceSchema,
disconnectProjectDeviceRowsSchema, disconnectProjectDeviceRowsSchema,
reconnectProjectDeviceRowsSchema,
restoreProjectDeviceRowsSchema,
synchronizeProjectDeviceRowsSchema, synchronizeProjectDeviceRowsSchema,
updateProjectDeviceSchema, updateProjectDeviceSchema,
} from "../../shared/validation/project-device.schemas.js"; } from "../../shared/validation/project-device.schemas.js";
@@ -116,18 +118,35 @@ export async function synchronizeProjectDeviceRows(req: Request, res: Response)
return res.status(400).json({ error: parsed.error.flatten() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
try { try {
const preview = await projectDeviceSyncService.synchronize( const result = await projectDeviceSyncService.synchronize(
projectId, projectId,
projectDeviceId, projectDeviceId,
parsed.data.rowIds, parsed.data.rowIds,
parsed.data.fields parsed.data.fields
); );
return res.json(preview); return res.json(result);
} catch (error) { } catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." }); return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." });
} }
} }
export async function restoreProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = restoreProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const preview = await projectDeviceSyncService.restore(projectId, projectDeviceId, parsed.data.rows);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to restore rows." });
}
}
export async function disconnectProjectDeviceRows(req: Request, res: Response) { export async function disconnectProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params; const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") { if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
@@ -144,3 +163,20 @@ export async function disconnectProjectDeviceRows(req: Request, res: Response) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." }); return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." });
} }
} }
export async function reconnectProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = reconnectProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const preview = await projectDeviceSyncService.reconnect(projectId, projectDeviceId, parsed.data.rowIds);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reconnect rows." });
}
}
@@ -6,6 +6,8 @@ import {
disconnectProjectDeviceRows, disconnectProjectDeviceRows,
getProjectDeviceSyncPreview, getProjectDeviceSyncPreview,
listProjectDevicesByProject, listProjectDevicesByProject,
reconnectProjectDeviceRows,
restoreProjectDeviceRows,
synchronizeProjectDeviceRows, synchronizeProjectDeviceRows,
updateProjectDevice, updateProjectDevice,
} from "../controllers/project-device.controller.js"; } from "../controllers/project-device.controller.js";
@@ -17,6 +19,8 @@ projectDeviceRouter.post("/projects/:projectId", createProjectDevice);
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject); projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview); projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows); projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/restore", restoreProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows); projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/reconnect", reconnectProjectDeviceRows);
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice); projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice); projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
@@ -28,5 +28,33 @@ export const disconnectProjectDeviceRowsSchema = z.object({
rowIds: z.array(z.string().min(1)).min(1), rowIds: z.array(z.string().min(1)).min(1),
}); });
const projectDeviceRestoreValuesSchema = z.object({
name: z.string().optional(),
displayName: z.string().optional(),
phaseType: z.enum(["single_phase", "three_phase"]).optional(),
connectionKind: z.string().nullable().optional(),
costGroup: z.string().nullable().optional(),
category: z.string().nullable().optional(),
quantity: z.number().min(0).optional(),
powerPerUnit: z.number().min(0).optional(),
simultaneityFactor: z.number().min(0).max(1).optional(),
cosPhi: z.number().min(0).max(1).nullable().optional(),
remark: z.string().nullable().optional(),
});
export const restoreProjectDeviceRowsSchema = z.object({
rows: z
.array(
z.object({
rowId: z.string().min(1),
values: projectDeviceRestoreValuesSchema,
overriddenFields: z.array(z.enum(projectDeviceSyncFields)),
})
)
.min(1),
});
export const reconnectProjectDeviceRowsSchema = disconnectProjectDeviceRowsSchema;
export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>; export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>;
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>; export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
+101 -6
View File
@@ -1,6 +1,8 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js"; import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js";
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
import { db } from "../src/db/client.js";
function projectDevice() { function projectDevice() {
return { return {
@@ -56,6 +58,7 @@ function linkedRow() {
function createService() { function createService() {
const rows = [linkedRow()]; const rows = [linkedRow()];
const updates: Array<Record<string, unknown>> = []; const updates: Array<Record<string, unknown>> = [];
let transactionCalls = 0;
const service = new ProjectDeviceSyncService({ const service = new ProjectDeviceSyncService({
projectDeviceRepository: { projectDeviceRepository: {
async findById() { async findById() {
@@ -64,15 +67,35 @@ function createService() {
}, },
deviceRowRepository: { deviceRowRepository: {
async listLinkedByProjectDevice() { async listLinkedByProjectDevice() {
return rows as never; return rows.filter((row) => row.linkedProjectDeviceId === "pd1") as never;
}, },
async update(rowId, input) { async listLinkStatesByIds() {
updates.push({ rowId, ...input }); return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never;
Object.assign(rows[0], input); },
updateLinkedRowsTransactional(_projectDeviceId, changes) {
transactionCalls += 1;
for (const change of changes) {
updates.push({ rowId: change.rowId, ...change.input });
Object.assign(rows.find((row) => row.id === change.rowId)!, change.input);
}
},
disconnectLinkedRowsTransactional(_projectDeviceId, rowIds) {
transactionCalls += 1;
for (const rowId of rowIds) {
const row = rows.find((entry) => entry.id === rowId)!;
updates.push({ rowId, linkedProjectDeviceId: undefined, displayName: row.displayName, quantity: row.quantity });
Object.assign(row, { linkedProjectDeviceId: null });
}
},
reconnectRowsTransactional(projectDeviceId, rowIds) {
transactionCalls += 1;
for (const rowId of rowIds) {
Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId });
}
}, },
} as never, } as never,
}); });
return { service, rows, updates }; return { service, rows, updates, transactionCalls: () => transactionCalls };
} }
describe("project device synchronization", () => { describe("project device synchronization", () => {
@@ -90,13 +113,14 @@ describe("project device synchronization", () => {
}); });
it("synchronizes only explicitly selected fields", async () => { it("synchronizes only explicitly selected fields", async () => {
const { service, rows } = createService(); const { service, rows, transactionCalls } = createService();
await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]); await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
assert.equal(rows[0].quantity, 6); assert.equal(rows[0].quantity, 6);
assert.equal(rows[0].powerPerUnit, 0.04); assert.equal(rows[0].powerPerUnit, 0.04);
assert.equal(rows[0].displayName, "Local display name"); assert.equal(rows[0].displayName, "Local display name");
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]); assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]);
assert.equal(transactionCalls(), 1);
}); });
it("rejects rows that are not linked to the selected project device", async () => { it("rejects rows that are not linked to the selected project device", async () => {
@@ -115,4 +139,75 @@ describe("project device synchronization", () => {
assert.equal(updates[0].displayName, "Local display name"); assert.equal(updates[0].displayName, "Local display name");
assert.equal(updates[0].quantity, 2); assert.equal(updates[0].quantity, 2);
}); });
it("restores synchronized values and override markers", async () => {
const { service, rows } = createService();
const result = await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
await service.restore("p1", "pd1", result.undo.rows);
assert.equal(rows[0].quantity, 2);
assert.equal(rows[0].powerPerUnit, 0.03);
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName", "quantity"]);
});
it("reconnects rows only while they remain disconnected", async () => {
const { service, rows } = createService();
const result = await service.disconnect("p1", "pd1", ["row1"]);
await service.reconnect("p1", "pd1", result.undo.rowIds);
assert.equal(rows[0].linkedProjectDeviceId, "pd1");
});
it("executes multi-row updates inside one synchronous transaction", () => {
const repository = new CircuitDeviceRowRepository();
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
let transactionCalls = 0;
let updateCalls = 0;
let returnedPromise = false;
(db as unknown as { transaction: (callback: (tx: unknown) => unknown) => void }).transaction = (callback) => {
transactionCalls += 1;
const fakeTx = {
update() {
return {
set() {
return {
where() {
return {
run() {
updateCalls += 1;
return { changes: 1 };
},
};
},
};
},
};
},
};
const result = callback(fakeTx);
returnedPromise = Boolean(result && typeof (result as Promise<unknown>).then === "function");
};
try {
const input = {
linkedProjectDeviceId: "pd1",
name: "Luminaire",
displayName: "Office lighting",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
};
repository.updateLinkedRowsTransactional("pd1", [
{ rowId: "r1", input },
{ rowId: "r2", input },
]);
assert.equal(transactionCalls, 1);
assert.equal(updateCalls, 2);
assert.equal(returnedPromise, false);
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
}); });