Add configurable distribution supply types

This commit is contained in:
2026-07-29 09:31:20 +02:00
parent 194bc9c0b1
commit b1a11397b3
43 changed files with 5697 additions and 126 deletions
+226 -4
View File
@@ -23,6 +23,7 @@ import {
listRooms,
importProjectTransfer,
synchronizeProjectDeviceRows,
updateDistributionBoard,
updateProjectDevice,
updateProjectSettings,
} from "../../../frontend/utils/api";
@@ -41,6 +42,10 @@ import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../../shared/constants/project-device-sync-fields";
import {
distributionBoardSupplyTypeLabels,
type DistributionBoardSupplyType,
} from "../../../shared/constants/distribution-board";
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
import {
ProjectSettingsModal,
@@ -74,6 +79,14 @@ export default function ProjectDetailPage() {
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
const [boardName, setBoardName] = useState("");
const [boardFloorId, setBoardFloorId] = useState("");
const [boardSupplyType, setBoardSupplyType] =
useState<DistributionBoardSupplyType>("AV");
const [editingBoard, setEditingBoard] =
useState<DistributionBoardDto | null>(null);
const [editingBoardFloorId, setEditingBoardFloorId] = useState("");
const [editingBoardSupplyType, setEditingBoardSupplyType] =
useState<DistributionBoardSupplyType>("AV");
const [floorName, setFloorName] = useState("");
const [roomNumber, setRoomNumber] = useState("");
const [roomName, setRoomName] = useState("");
@@ -158,6 +171,24 @@ export default function ProjectDetailPage() {
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
[circuitLists]
);
const enabledBoardSupplyTypes =
project?.enabledDistributionBoardSupplyTypes ?? [];
const usedBoardSupplyTypes = useMemo(
() =>
[
...new Set(
boards
.map((board) => board.supplyType)
.filter(
(
supplyType
): supplyType is DistributionBoardSupplyType =>
supplyType !== null
)
),
],
[boards]
);
function applyProjectRevision(currentRevision: number) {
setProject((current) =>
@@ -175,7 +206,11 @@ export default function ProjectDetailPage() {
try {
const result = await createDistributionBoard(
projectId,
boardName.trim(),
{
name: boardName.trim(),
floorId: boardFloorId || null,
supplyType: boardSupplyType,
},
project.currentRevision
);
setBoards((current) => [
@@ -185,6 +220,10 @@ export default function ProjectDetailPage() {
setCircuitLists(await listCircuitLists(projectId));
applyProjectRevision(result.history.currentRevision);
setBoardName("");
setBoardFloorId("");
setBoardSupplyType(
project.enabledDistributionBoardSupplyTypes[0]
);
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
@@ -351,6 +390,60 @@ export default function ProjectDetailPage() {
}
}
function openBoardEditor(board: DistributionBoardDto) {
setEditingBoard(board);
setEditingBoardFloorId(board.floorId ?? "");
setEditingBoardSupplyType(
board.supplyType ??
project?.enabledDistributionBoardSupplyTypes[0] ??
"AV"
);
}
function openBoardCreator() {
setBoardSupplyType(
project?.enabledDistributionBoardSupplyTypes[0] ?? "AV"
);
setStructureModal("board");
}
async function handleUpdateBoard(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !project || !editingBoard) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await updateDistributionBoard(
projectId,
editingBoard.id,
{
floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType,
},
project.currentRevision
);
setBoards((current) =>
current.map((board) =>
board.id === result.distributionBoard.id
? result.distributionBoard
: board
)
);
applyProjectRevision(result.history.currentRevision);
setEditingBoard(null);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Verteilung konnte nicht bearbeitet werden."
);
} finally {
setIsSaving(false);
}
}
async function handleExportProject() {
if (!project) {
return;
@@ -596,7 +689,7 @@ export default function ProjectDetailPage() {
</div>
<button
className="btn btn-sm btn-primary"
onClick={() => setStructureModal("board")}
onClick={openBoardCreator}
type="button"
>
Verteilung hinzufügen
@@ -607,6 +700,8 @@ export default function ProjectDetailPage() {
<thead>
<tr>
<th>Verteilung</th>
<th>Etage</th>
<th>Netzart</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
@@ -616,8 +711,28 @@ export default function ProjectDetailPage() {
return (
<tr key={board.id}>
<td>{board.name}</td>
<td>
{board.floorId
? floorById.get(board.floorId)?.name ?? "Unbekannt"
: "Ohne Etage"}
</td>
<td>
{board.supplyType
? distributionBoardSupplyTypeLabels[
board.supplyType
]
: "Nicht festgelegt"}
</td>
<td className="text-end">
<div className="d-inline-flex gap-2">
<div className="d-inline-flex flex-wrap justify-content-end gap-2">
<button
className="btn btn-sm btn-outline-secondary"
disabled={isSaving}
onClick={() => openBoardEditor(board)}
type="button"
>
Bearbeiten
</button>
{circuitList ? (
<Link
className="btn btn-sm btn-primary"
@@ -637,7 +752,7 @@ export default function ProjectDetailPage() {
})}
{!boards.length ? (
<tr>
<td colSpan={2} className="text-center text-secondary py-4">
<td colSpan={4} className="text-center text-secondary py-4">
Noch keine Verteilungen vorhanden.
</td>
</tr>
@@ -967,6 +1082,112 @@ export default function ProjectDetailPage() {
required
value={boardName}
/>
<div className="row g-3 mt-0">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="distribution-board-floor">
Etage
</label>
<select
className="form-select"
id="distribution-board-floor"
onChange={(event) => setBoardFloorId(event.target.value)}
value={boardFloorId}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="distribution-board-supply-type"
>
Netzart
</label>
<select
className="form-select"
id="distribution-board-supply-type"
onChange={(event) =>
setBoardSupplyType(
event.target.value as DistributionBoardSupplyType
)
}
value={boardSupplyType}
>
{enabledBoardSupplyTypes.map((supplyType) => (
<option key={supplyType} value={supplyType}>
{distributionBoardSupplyTypeLabels[supplyType]}
</option>
))}
</select>
</div>
</div>
</FormModal>
) : null}
{editingBoard ? (
<FormModal
description={`${editingBoard.name}: Etagenzuordnung und Netzart ändern.`}
isSaving={isSaving}
onClose={() => setEditingBoard(null)}
onSubmit={handleUpdateBoard}
submitLabel="Änderungen speichern"
title="Verteilung bearbeiten"
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="edit-distribution-board-floor"
>
Etage
</label>
<select
autoFocus
className="form-select"
id="edit-distribution-board-floor"
onChange={(event) =>
setEditingBoardFloorId(event.target.value)
}
value={editingBoardFloorId}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="edit-distribution-board-supply-type"
>
Netzart
</label>
<select
className="form-select"
id="edit-distribution-board-supply-type"
onChange={(event) =>
setEditingBoardSupplyType(
event.target.value as DistributionBoardSupplyType
)
}
value={editingBoardSupplyType}
>
{enabledBoardSupplyTypes.map((supplyType) => (
<option key={supplyType} value={supplyType}>
{distributionBoardSupplyTypeLabels[supplyType]}
</option>
))}
</select>
</div>
</div>
</FormModal>
) : null}
@@ -1082,6 +1303,7 @@ export default function ProjectDetailPage() {
onImport={handleImportProject}
onSave={handleSaveProjectSettings}
project={project}
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
/>
) : null}
</main>
@@ -0,0 +1,2 @@
ALTER TABLE `distribution_boards` ADD `floor_id` text REFERENCES floors(id);--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `supply_type` text;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `projects` ADD `enabled_distribution_board_supply_types` text DEFAULT '["AV","SV","EV","USV","MSR","SiBe"]' NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -120,6 +120,20 @@
"when": 1785254079435,
"tag": "0016_dashing_darkstar",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1785307189539,
"tag": "0017_vengeful_romulus",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1785308458789,
"tag": "0018_fancy_argent",
"breakpoints": true
}
]
}
@@ -6,9 +6,19 @@ import {
createDistributionBoardInsertProjectCommand,
distributionBoardDeleteCommandType,
distributionBoardInsertCommandType,
normalizeDistributionBoardStructureProjectCommand,
type DistributionBoardStructureProjectCommand,
type DistributionBoardStructureSnapshot,
legacyDistributionBoardStructureCommandSchemaVersion,
} from "../../domain/models/distribution-board-structure-project-command.model.js";
import {
assertDistributionBoardUpdateProjectCommand,
createDistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateField,
type DistributionBoardUpdatePatch,
type DistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateValues,
} from "../../domain/models/distribution-board-project-command.model.js";
import type {
DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput,
@@ -18,6 +28,7 @@ import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
@@ -35,26 +46,52 @@ export class DistributionBoardStructureProjectCommandRepository
);
}
executeUpdate(
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardUpdateProjectCommand;
}
) {
assertDistributionBoardUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.update(tx, input)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: DistributionBoardStructureProjectCommand
): DistributionBoardStructureProjectCommand {
if (command.type === distributionBoardInsertCommandType) {
assertDistributionBoardInsertProjectCommand(command);
return this.insert(
const normalized =
normalizeDistributionBoardStructureProjectCommand(command);
if (normalized.type === distributionBoardInsertCommandType) {
assertDistributionBoardInsertProjectCommand(normalized);
const inverse = this.insert(
database,
projectId,
command.payload.structure
normalized.payload.structure
);
return command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
? toLegacyStructureCommand(inverse)
: inverse;
}
if (command.type === distributionBoardDeleteCommandType) {
assertDistributionBoardDeleteProjectCommand(command);
return this.delete(
if (normalized.type === distributionBoardDeleteCommandType) {
assertDistributionBoardDeleteProjectCommand(normalized);
const inverse = this.delete(
database,
projectId,
command.payload.structure
normalized.payload.structure
);
return command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
? toLegacyStructureCommand(inverse)
: inverse;
}
throw new Error("Unsupported distribution-board structure command.");
}
@@ -73,13 +110,33 @@ export class DistributionBoardStructureProjectCommandRepository
);
}
const project = database
.select({ id: projects.id })
.select({
id: projects.id,
enabledSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
})
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
assertSupplyTypeEnabled(
project.enabledSupplyTypes,
structure.distributionBoard.supplyType
);
if (structure.distributionBoard.floorId !== null) {
const floor = database
.select({ projectId: floors.projectId })
.from(floors)
.where(eq(floors.id, structure.distributionBoard.floorId))
.get();
if (!floor || floor.projectId !== projectId) {
throw new Error(
"Distribution-board floor does not belong to project."
);
}
}
const existingBoard = database
.select({ id: distributionBoards.id })
.from(distributionBoards)
@@ -179,6 +236,139 @@ export class DistributionBoardStructureProjectCommandRepository
}
return createDistributionBoardInsertProjectCommand(structure);
}
private update(
database: AppDatabase,
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardUpdateProjectCommand;
}
) {
const current = database
.select()
.from(distributionBoards)
.where(
and(
eq(
distributionBoards.id,
input.command.payload.distributionBoardId
),
eq(distributionBoards.projectId, input.projectId)
)
)
.get();
if (!current) {
throw new Error("Distribution board does not belong to project.");
}
const project = database
.select({
enabledSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
})
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as DistributionBoardUpdatePatch;
if (patch.floorId !== undefined && patch.floorId !== null) {
const floor = database
.select({ projectId: floors.projectId })
.from(floors)
.where(eq(floors.id, patch.floorId))
.get();
if (!floor || floor.projectId !== input.projectId) {
throw new Error(
"Distribution-board floor does not belong to project."
);
}
}
if (patch.supplyType !== undefined) {
assertSupplyTypeEnabled(
project.enabledSupplyTypes,
patch.supplyType
);
}
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
getDistributionBoardFieldValue(current, change.field),
])
) as DistributionBoardUpdatePatch;
const inverse = createDistributionBoardUpdateProjectCommand(
current.id,
inversePatch
);
const updated = database
.update(distributionBoards)
.set(patch)
.where(
and(
eq(distributionBoards.id, current.id),
eq(distributionBoards.projectId, input.projectId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Distribution board changed before command execution."
);
}
return inverse;
}
}
function assertSupplyTypeEnabled(
enabledSupplyTypes: (typeof projects.$inferSelect)["enabledDistributionBoardSupplyTypes"],
supplyType: (typeof distributionBoards.$inferSelect)["supplyType"]
) {
if (
supplyType !== null &&
!enabledSupplyTypes.includes(supplyType)
) {
throw new Error(
`Die Netzart ${supplyType} ist in den Projekteinstellungen nicht aktiviert.`
);
}
}
function toLegacyStructureCommand(
command:
| ReturnType<typeof createDistributionBoardInsertProjectCommand>
| ReturnType<typeof createDistributionBoardDeleteProjectCommand>
): DistributionBoardStructureProjectCommand {
const {
floorId: _floorId,
supplyType: _supplyType,
...distributionBoard
} = command.payload.structure.distributionBoard;
return {
schemaVersion: legacyDistributionBoardStructureCommandSchemaVersion,
type: command.type,
payload: {
structure: {
...command.payload.structure,
distributionBoard,
},
},
} as DistributionBoardStructureProjectCommand;
}
function getDistributionBoardFieldValue<
TField extends DistributionBoardUpdateField,
>(
distributionBoard: typeof distributionBoards.$inferSelect,
field: TField
): DistributionBoardUpdateValues[TField] {
return distributionBoard[field] as DistributionBoardUpdateValues[TField];
}
function structureMatches(
@@ -24,6 +24,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { consumers } from "../schema/consumers.js";
import { floors } from "../schema/floors.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
@@ -129,6 +130,17 @@ export class ProjectLocationStructureProjectCommandRepository
"A project floor with assigned rooms cannot be removed by history."
);
}
const referencedDistributionBoard = database
.select({ id: distributionBoards.id })
.from(distributionBoards)
.where(eq(distributionBoards.floorId, expected.id))
.limit(1)
.get();
if (referencedDistributionBoard) {
throw new Error(
"A project floor assigned to a distribution board cannot be removed by history."
);
}
const deleted = database
.delete(floors)
.where(
@@ -1,8 +1,10 @@
import { eq } from "drizzle-orm";
import { and, eq, isNotNull } from "drizzle-orm";
import {
assertProjectSettingsUpdateProjectCommand,
createProjectSettingsUpdateProjectCommand,
legacyProjectSettingsUpdateCommandSchemaVersion,
normalizeProjectSettingsValues,
previousProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.js";
import type {
@@ -11,6 +13,7 @@ import type {
} from "../../domain/ports/project-settings-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { projects } from "../schema/projects.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ProjectSettingsProjectCommandRepository
@@ -39,18 +42,7 @@ export class ProjectSettingsProjectCommandRepository
if (!current) {
throw new Error("Project not found.");
}
const target: ProjectSettingsValues =
input.command.schemaVersion ===
legacyProjectSettingsUpdateCommandSchemaVersion
? {
name: current.name,
internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
buildingOwner: current.buildingOwner,
description: current.description,
...input.command.payload,
}
: input.command.payload;
const target = normalizeProjectSettingsValues(input.command, current);
if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change.");
}
@@ -66,7 +58,23 @@ export class ProjectSettingsProjectCommandRepository
threePhaseVoltageV: current.threePhaseVoltageV,
},
}
: createProjectSettingsUpdateProjectCommand({
: input.command.schemaVersion ===
previousProjectSettingsUpdateCommandSchemaVersion
? {
schemaVersion:
previousProjectSettingsUpdateCommandSchemaVersion,
type: input.command.type,
payload: {
name: current.name,
internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
buildingOwner: current.buildingOwner,
description: current.description,
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
},
}
: createProjectSettingsUpdateProjectCommand({
name: current.name,
internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
@@ -74,7 +82,10 @@ export class ProjectSettingsProjectCommandRepository
description: current.description,
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes,
});
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
const updated = tx
.update(projects)
.set(target)
@@ -100,5 +111,54 @@ function projectSettingsEqual(
current.description === target.description &&
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV
&& sameSupplyTypes(
current.enabledDistributionBoardSupplyTypes,
target.enabledDistributionBoardSupplyTypes
)
);
}
function sameSupplyTypes(
left: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"],
right: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"]
) {
return (
left.length === right.length &&
left.every((value, index) => value === right[index])
);
}
function assertDisabledSupplyTypesAreUnused(
database: AppDatabase,
projectId: string,
target: ProjectSettingsValues
) {
const used = database
.select({ supplyType: distributionBoards.supplyType })
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
isNotNull(distributionBoards.supplyType)
)
)
.all();
const disabledUsedTypes = [
...new Set(
used
.map((entry) => entry.supplyType)
.filter(
(supplyType) =>
supplyType !== null &&
!target.enabledDistributionBoardSupplyTypes.some(
(enabled) => enabled === supplyType
)
)
),
];
if (disabledUsedTypes.length > 0) {
throw new Error(
`Verwendete Netzarten können nicht deaktiviert werden: ${disabledUsedTypes.join(", ")}.`
);
}
}
@@ -4,8 +4,10 @@ import { ProjectRevisionConflictError } from "../../domain/errors/project-revisi
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import {
distributionBoardProjectStateSnapshotSchemaVersion,
deserializeProjectStateSnapshot,
legacyProjectStateSnapshotSchemaVersion,
previousProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
@@ -141,6 +143,9 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
}
if (
stored.schemaVersion !== legacyProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== previousProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
distributionBoardProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== projectStateSnapshotSchemaVersion
) {
throw new Error("Project snapshot schema version is not supported.");
@@ -194,6 +194,8 @@ function replaceProjectState(
description: state.project.description,
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
threePhaseVoltageV: state.project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
state.project.enabledDistributionBoardSupplyTypes,
})
.where(eq(projects.id, state.project.id))
.run();
@@ -38,6 +38,8 @@ export function readProjectStateSnapshot(
description: projects.description,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
currentRevision: projects.currentRevision,
})
.from(projects)
@@ -182,6 +184,8 @@ export function readProjectStateSnapshot(
description: project.description,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
project.enabledDistributionBoardSupplyTypes,
},
distributionBoards: boardRows,
circuitLists: listRows,
@@ -74,12 +74,18 @@ export class ProjectTransferRepository implements ProjectTransferStore {
}
private parseVerified(value: unknown) {
const rawPayloadSha256 =
isPlainObject(value) &&
isPlainObject(value.projectState)
? hashProjectStatePayload(JSON.stringify(value.projectState))
: null;
const transfer = parseProjectTransferEnvelope(value);
const payloadJson = serializeProjectStateSnapshot(
transfer.projectState
);
if (
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
rawPayloadSha256 !== transfer.payloadSha256
) {
throw new Error("Project transfer checksum verification failed.");
}
@@ -87,6 +93,10 @@ export class ProjectTransferRepository implements ProjectTransferStore {
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function insertProjectState(
database: AppDatabase,
state: ReturnType<typeof remapProjectState>
@@ -5,6 +5,7 @@ import { projects } from "../schema/projects.js";
import type {
CreateProjectInput,
} from "../../shared/validation/project-structure.schemas.js";
import { defaultDistributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
export class ProjectRepository {
constructor(private readonly database: AppDatabase) {}
@@ -26,6 +27,9 @@ export class ProjectRepository {
description: input.description ?? null,
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
enabledDistributionBoardSupplyTypes: [
...defaultDistributionBoardSupplyTypes,
],
currentRevision: 0,
};
await db.insert(projects).values(project);
+6
View File
@@ -1,5 +1,7 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { floors } from "./floors.js";
import { projects } from "./projects.js";
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
export const distributionBoards = sqliteTable("distribution_boards", {
id: text("id").primaryKey(),
@@ -7,5 +9,9 @@ export const distributionBoards = sqliteTable("distribution_boards", {
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
floorId: text("floor_id").references(() => floors.id, {
onDelete: "set null",
}),
supplyType: text("supply_type").$type<DistributionBoardSupplyType>(),
});
+9
View File
@@ -1,4 +1,6 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
@@ -9,5 +11,12 @@ export const projects = sqliteTable("projects", {
description: text("description"),
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
enabledDistributionBoardSupplyTypes: text(
"enabled_distribution_board_supply_types",
{ mode: "json" }
)
.$type<DistributionBoardSupplyType[]>()
.notNull()
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
currentRevision: integer("current_revision").notNull().default(0),
});
@@ -0,0 +1,112 @@
import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardUpdateCommandType =
"distribution-board.update" as const;
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
export interface DistributionBoardUpdateValues {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
}
export type DistributionBoardUpdateField =
keyof DistributionBoardUpdateValues;
export type DistributionBoardUpdatePatch =
Partial<DistributionBoardUpdateValues>;
export interface DistributionBoardUpdateFieldChange<
TField extends DistributionBoardUpdateField =
DistributionBoardUpdateField,
> {
field: TField;
value: DistributionBoardUpdateValues[TField];
}
export interface DistributionBoardUpdateCommandPayload {
distributionBoardId: string;
changes: DistributionBoardUpdateFieldChange[];
}
export interface DistributionBoardUpdateProjectCommand
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
schemaVersion: typeof distributionBoardUpdateCommandSchemaVersion;
type: typeof distributionBoardUpdateCommandType;
}
export function createDistributionBoardUpdateProjectCommand(
distributionBoardId: string,
patch: DistributionBoardUpdatePatch
): DistributionBoardUpdateProjectCommand {
const command: DistributionBoardUpdateProjectCommand = {
schemaVersion: distributionBoardUpdateCommandSchemaVersion,
type: distributionBoardUpdateCommandType,
payload: {
distributionBoardId,
changes: Object.entries(patch).map(([field, value]) => ({
field: field as DistributionBoardUpdateField,
value,
})) as DistributionBoardUpdateFieldChange[],
},
};
assertDistributionBoardUpdateProjectCommand(command);
return command;
}
export function assertDistributionBoardUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardUpdateProjectCommand {
if (
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion ||
command.type !== distributionBoardUpdateCommandType ||
!isPlainObject(command.payload)
) {
throw new Error("Unsupported distribution-board update command.");
}
const { distributionBoardId, changes } = command.payload;
if (
typeof distributionBoardId !== "string" ||
!distributionBoardId.trim() ||
!Array.isArray(changes) ||
changes.length === 0
) {
throw new Error(
"Distribution-board update command requires an id and changes."
);
}
const seen = new Set<string>();
for (const change of changes) {
if (
!isPlainObject(change) ||
(change.field !== "floorId" && change.field !== "supplyType") ||
seen.has(change.field)
) {
throw new Error(
"Distribution-board update contains an invalid or duplicate field."
);
}
if (change.field === "floorId") {
if (
change.value !== null &&
(typeof change.value !== "string" || !change.value.trim())
) {
throw new Error("floorId must be a non-empty string or null.");
}
} else if (
change.value !== null &&
!distributionBoardSupplyTypes.includes(
change.value as DistributionBoardSupplyType
)
) {
throw new Error("supplyType is invalid.");
}
seen.add(change.field);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -1,11 +1,16 @@
import crypto from "node:crypto";
import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardInsertCommandType =
"distribution-board.insert" as const;
export const distributionBoardDeleteCommandType =
"distribution-board.delete" as const;
export const distributionBoardStructureCommandSchemaVersion = 1 as const;
export const legacyDistributionBoardStructureCommandSchemaVersion = 1 as const;
export const distributionBoardStructureCommandSchemaVersion = 2 as const;
export const defaultCircuitSectionDefinitions = [
{
@@ -34,11 +39,23 @@ export const defaultCircuitSectionDefinitions = [
},
] as const;
interface LegacyDistributionBoardStructureSnapshot {
distributionBoard: {
id: string;
projectId: string;
name: string;
};
circuitList: DistributionBoardStructureSnapshot["circuitList"];
sections: DistributionBoardStructureSnapshot["sections"];
}
export interface DistributionBoardStructureSnapshot {
distributionBoard: {
id: string;
projectId: string;
name: string;
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
};
circuitList: {
id: string;
@@ -56,29 +73,52 @@ export interface DistributionBoardStructureSnapshot {
}>;
}
interface DistributionBoardStructureCommandPayload {
structure: DistributionBoardStructureSnapshot;
interface DistributionBoardStructureCommandPayload<
TStructure = DistributionBoardStructureSnapshot,
> {
structure: TStructure;
}
interface CurrentDistributionBoardStructureProjectCommand
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
type:
| typeof distributionBoardInsertCommandType
| typeof distributionBoardDeleteCommandType;
}
interface LegacyDistributionBoardStructureProjectCommand
extends SerializedProjectCommand<
DistributionBoardStructureCommandPayload<LegacyDistributionBoardStructureSnapshot>
> {
schemaVersion: typeof legacyDistributionBoardStructureCommandSchemaVersion;
type:
| typeof distributionBoardInsertCommandType
| typeof distributionBoardDeleteCommandType;
}
export interface DistributionBoardInsertProjectCommand
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
extends CurrentDistributionBoardStructureProjectCommand {
type: typeof distributionBoardInsertCommandType;
}
export interface DistributionBoardDeleteProjectCommand
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
extends CurrentDistributionBoardStructureProjectCommand {
type: typeof distributionBoardDeleteCommandType;
}
export type DistributionBoardStructureProjectCommand =
| DistributionBoardInsertProjectCommand
| DistributionBoardDeleteProjectCommand;
| DistributionBoardDeleteProjectCommand
| LegacyDistributionBoardStructureProjectCommand;
export function createDistributionBoardStructureSnapshot(
projectId: string,
name: string
name: string,
input: {
floorId?: string | null;
supplyType?: DistributionBoardSupplyType | null;
} = {}
): DistributionBoardStructureSnapshot {
const normalizedName = name.trim();
assertNonEmptyString(projectId, "projectId");
@@ -89,6 +129,8 @@ export function createDistributionBoardStructureSnapshot(
id: structureId,
projectId,
name: normalizedName,
floorId: input.floorId ?? null,
supplyType: input.supplyType ?? null,
},
circuitList: {
id: structureId,
@@ -132,7 +174,7 @@ export function createDistributionBoardDeleteProjectCommand(
export function assertDistributionBoardInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardInsertProjectCommand {
): asserts command is DistributionBoardStructureProjectCommand {
assertDistributionBoardStructureProjectCommand(
command,
distributionBoardInsertCommandType
@@ -141,23 +183,88 @@ export function assertDistributionBoardInsertProjectCommand(
export function assertDistributionBoardDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardDeleteProjectCommand {
): asserts command is DistributionBoardStructureProjectCommand {
assertDistributionBoardStructureProjectCommand(
command,
distributionBoardDeleteCommandType
);
}
export function normalizeDistributionBoardStructureProjectCommand(
command: DistributionBoardStructureProjectCommand
): DistributionBoardInsertProjectCommand | DistributionBoardDeleteProjectCommand {
if (
command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
) {
const structure: DistributionBoardStructureSnapshot = {
...command.payload.structure,
distributionBoard: {
...command.payload.structure.distributionBoard,
floorId: null,
supplyType: null,
},
};
return command.type === distributionBoardInsertCommandType
? createDistributionBoardInsertProjectCommand(structure)
: createDistributionBoardDeleteProjectCommand(structure);
}
return command as
| DistributionBoardInsertProjectCommand
| DistributionBoardDeleteProjectCommand;
}
export function assertDistributionBoardStructureSnapshot(
structure: unknown
): asserts structure is DistributionBoardStructureSnapshot {
assertDistributionBoardStructureSnapshotVersion(structure, false);
}
function assertDistributionBoardStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof distributionBoardInsertCommandType
| typeof distributionBoardDeleteCommandType
) {
if (
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported distribution-board structure command.");
}
if (
command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
) {
assertDistributionBoardStructureSnapshotVersion(
command.payload.structure,
true
);
return;
}
if (
command.schemaVersion !== distributionBoardStructureCommandSchemaVersion
) {
throw new Error("Unsupported distribution-board structure command.");
}
assertDistributionBoardStructureSnapshotVersion(
command.payload.structure,
false
);
}
function assertDistributionBoardStructureSnapshotVersion(
structure: unknown,
legacy: boolean
) {
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
throw new Error("Distribution-board structure is invalid.");
}
const { distributionBoard, circuitList, sections } = structure;
if (
!isPlainObject(distributionBoard) ||
Object.keys(distributionBoard).length !== 3 ||
Object.keys(distributionBoard).length !== (legacy ? 3 : 5) ||
!isPlainObject(circuitList) ||
Object.keys(circuitList).length !== 4 ||
!Array.isArray(sections) ||
@@ -165,8 +272,15 @@ export function assertDistributionBoardStructureSnapshot(
) {
throw new Error("Distribution-board structure is incomplete.");
}
for (const [field, value] of Object.entries(distributionBoard)) {
assertNonEmptyString(value, `distributionBoard.${field}`);
for (const field of ["id", "projectId", "name"] as const) {
assertNonEmptyString(
distributionBoard[field],
`distributionBoard.${field}`
);
}
if (!legacy) {
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
assertNullableSupplyType(distributionBoard.supplyType);
}
for (const [field, value] of Object.entries(circuitList)) {
assertNonEmptyString(value, `circuitList.${field}`);
@@ -174,22 +288,16 @@ export function assertDistributionBoardStructureSnapshot(
if (
circuitList.projectId !== distributionBoard.projectId ||
circuitList.distributionBoardId !== distributionBoard.id ||
circuitList.name !==
`${distributionBoard.name} Stromkreisliste`
circuitList.name !== `${distributionBoard.name} Stromkreisliste`
) {
throw new Error(
"Distribution board and circuit list are inconsistent."
);
throw new Error("Distribution board and circuit list are inconsistent.");
}
const sectionIds = new Set<string>();
for (let index = 0; index < sections.length; index += 1) {
const section = sections[index];
const expected = defaultCircuitSectionDefinitions[index];
if (
!isPlainObject(section) ||
Object.keys(section).length !== 6
) {
if (!isPlainObject(section) || Object.keys(section).length !== 6) {
throw new Error("Default circuit section is invalid.");
}
assertNonEmptyString(section.id, "section.id");
@@ -211,22 +319,23 @@ export function assertDistributionBoardStructureSnapshot(
}
}
function assertDistributionBoardStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof distributionBoardInsertCommandType
| typeof distributionBoardDeleteCommandType
) {
if (
command.schemaVersion !==
distributionBoardStructureCommandSchemaVersion ||
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported distribution-board structure command.");
function assertNullableId(value: unknown, field: string) {
if (value !== null) {
assertNonEmptyString(value, field);
}
}
function assertNullableSupplyType(
value: unknown
): asserts value is DistributionBoardSupplyType | null {
if (
value !== null &&
!distributionBoardSupplyTypes.includes(
value as DistributionBoardSupplyType
)
) {
throw new Error("distributionBoard.supplyType is invalid.");
}
assertDistributionBoardStructureSnapshot(command.payload.structure);
}
function assertNonEmptyString(
@@ -1,16 +1,21 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
export const projectSettingsUpdateCommandType =
"project.update-settings" as const;
export const legacyProjectSettingsUpdateCommandSchemaVersion = 1 as const;
export const projectSettingsUpdateCommandSchemaVersion = 2 as const;
export const previousProjectSettingsUpdateCommandSchemaVersion = 2 as const;
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
export interface LegacyProjectSettingsValues {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
export interface ProjectSettingsValues extends LegacyProjectSettingsValues {
export interface PreviousProjectSettingsValues extends LegacyProjectSettingsValues {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
@@ -18,6 +23,10 @@ export interface ProjectSettingsValues extends LegacyProjectSettingsValues {
description: string | null;
}
export interface ProjectSettingsValues extends PreviousProjectSettingsValues {
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
export interface LegacyProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<LegacyProjectSettingsValues> {
schemaVersion: typeof legacyProjectSettingsUpdateCommandSchemaVersion;
@@ -30,8 +39,15 @@ export interface CurrentProjectSettingsUpdateProjectCommand
type: typeof projectSettingsUpdateCommandType;
}
export interface PreviousProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export type ProjectSettingsUpdateProjectCommand =
| LegacyProjectSettingsUpdateProjectCommand
| PreviousProjectSettingsUpdateProjectCommand
| CurrentProjectSettingsUpdateProjectCommand;
export function createProjectSettingsUpdateProjectCommand(
@@ -53,6 +69,8 @@ export function assertProjectSettingsUpdateProjectCommand(
command.type !== projectSettingsUpdateCommandType ||
(command.schemaVersion !==
legacyProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !==
previousProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion)
) {
throw new Error("Unsupported project settings update command.");
@@ -66,6 +84,10 @@ export function assertProjectSettingsUpdateProjectCommand(
assertLegacyValues(command.payload);
return;
}
const expectedKeyCount =
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion
? 7
: 8;
if (
!isTrimmedString(command.payload.name, 1, 200) ||
!isNullableTrimmedString(command.payload.internalProjectNumber, 100) ||
@@ -74,12 +96,61 @@ export function assertProjectSettingsUpdateProjectCommand(
!isNullableTrimmedString(command.payload.description, 2000) ||
!isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) ||
!isPositiveFiniteNumber(command.payload.threePhaseVoltageV) ||
Object.keys(command.payload).length !== 7
Object.keys(command.payload).length !== expectedKeyCount
) {
throw new Error(
"Project settings update command contains invalid values."
);
}
if (
command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
!isValidSupplyTypes(
command.payload.enabledDistributionBoardSupplyTypes
)
) {
throw new Error(
"Project settings update command contains invalid supply types."
);
}
}
export function normalizeProjectSettingsValues(
command: ProjectSettingsUpdateProjectCommand,
current: ProjectSettingsValues
): ProjectSettingsValues {
if (command.schemaVersion === legacyProjectSettingsUpdateCommandSchemaVersion) {
return {
...current,
...command.payload,
};
}
if (
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion
) {
return {
...command.payload,
enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes,
};
}
return command.payload;
}
function isValidSupplyTypes(
value: unknown
): value is DistributionBoardSupplyType[] {
return (
Array.isArray(value) &&
value.length > 0 &&
new Set(value).size === value.length &&
value.every(
(entry) =>
typeof entry === "string" &&
distributionBoardSupplyTypes.includes(
entry as DistributionBoardSupplyType
)
)
);
}
function assertLegacyValues(
+113 -12
View File
@@ -1,7 +1,13 @@
import { z } from "zod";
import {
defaultDistributionBoardSupplyTypes,
distributionBoardSupplyTypes,
} from "../../shared/constants/distribution-board.js";
export const legacyProjectStateSnapshotSchemaVersion = 1 as const;
export const projectStateSnapshotSchemaVersion = 2 as const;
export const previousProjectStateSnapshotSchemaVersion = 2 as const;
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
export const projectStateSnapshotSchemaVersion = 4 as const;
const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable();
@@ -23,7 +29,14 @@ const projectSchema = legacyProjectSchema.extend({
description: z.string().trim().min(1).max(2000).nullable(),
});
const distributionBoardSchema = z
const currentProjectSchema = projectSchema.extend({
enabledDistributionBoardSupplyTypes: z
.array(z.enum(distributionBoardSupplyTypes))
.min(1)
.refine((values) => new Set(values).size === values.length),
});
const legacyDistributionBoardSchema = z
.object({
id: idSchema,
projectId: idSchema,
@@ -31,6 +44,13 @@ const distributionBoardSchema = z
})
.strict();
const distributionBoardSchema = legacyDistributionBoardSchema
.extend({
floorId: idSchema.nullable(),
supplyType: z.enum(distributionBoardSupplyTypes).nullable(),
})
.strict();
const circuitListSchema = z
.object({
id: idSchema,
@@ -140,8 +160,7 @@ const roomSchema = z
})
.strict();
const projectStateSnapshotContents = {
distributionBoards: z.array(distributionBoardSchema),
const commonProjectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema),
@@ -154,15 +173,37 @@ const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: legacyProjectSchema,
...projectStateSnapshotContents,
distributionBoards: z.array(legacyDistributionBoardSchema),
...commonProjectStateSnapshotContents,
})
.strict();
const previousProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
...commonProjectStateSnapshotContents,
})
.strict();
const distributionBoardProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(
distributionBoardProjectStateSnapshotSchemaVersion
),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
...commonProjectStateSnapshotContents,
})
.strict();
export const projectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
...projectStateSnapshotContents,
project: currentProjectSchema,
distributionBoards: z.array(distributionBoardSchema),
...commonProjectStateSnapshotContents,
})
.strict();
@@ -176,20 +217,34 @@ export function parseProjectStateSnapshot(
const version = isPlainObject(value) ? value.schemaVersion : undefined;
const snapshot =
version === legacyProjectStateSnapshotSchemaVersion
? upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value)
? upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value)
)
)
)
: projectStateSnapshotSchema.parse(value);
: version === previousProjectStateSnapshotSchemaVersion
? upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
previousProjectStateSnapshotSchema.parse(value)
)
)
: version === distributionBoardProjectStateSnapshotSchemaVersion
? upgradeDistributionBoardProjectStateSnapshot(
distributionBoardProjectStateSnapshotSchema.parse(value)
)
: projectStateSnapshotSchema.parse(value);
assertProjectStateSnapshotRelations(snapshot);
return snapshot;
}
function upgradeLegacyProjectStateSnapshot(
snapshot: z.infer<typeof legacyProjectStateSnapshotSchema>
): ProjectStateSnapshot {
): z.infer<typeof previousProjectStateSnapshotSchema> {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: previousProjectStateSnapshotSchemaVersion,
project: {
...snapshot.project,
internalProjectNumber: null,
@@ -200,6 +255,35 @@ function upgradeLegacyProjectStateSnapshot(
};
}
function upgradePreviousProjectStateSnapshot(
snapshot: z.infer<typeof previousProjectStateSnapshotSchema>
): z.infer<typeof distributionBoardProjectStateSnapshotSchema> {
return {
...snapshot,
schemaVersion: distributionBoardProjectStateSnapshotSchemaVersion,
distributionBoards: snapshot.distributionBoards.map((board) => ({
...board,
floorId: null,
supplyType: null,
})),
};
}
function upgradeDistributionBoardProjectStateSnapshot(
snapshot: z.infer<typeof distributionBoardProjectStateSnapshotSchema>
): ProjectStateSnapshot {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
...snapshot.project,
enabledDistributionBoardSupplyTypes: [
...defaultDistributionBoardSupplyTypes,
],
},
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -245,6 +329,23 @@ function assertProjectStateSnapshotRelations(
for (const board of snapshot.distributionBoards) {
assertProjectOwnership(board.projectId, projectId, "distribution board");
if (board.floorId !== null) {
assertReference(
floorIds,
board.floorId,
"distribution board floor"
);
}
if (
board.supplyType !== null &&
!snapshot.project.enabledDistributionBoardSupplyTypes.includes(
board.supplyType
)
) {
throw new Error(
"Snapshot distribution board uses a disabled supply type."
);
}
}
for (const list of snapshot.circuitLists) {
assertProjectOwnership(list.projectId, projectId, "circuit list");
@@ -94,6 +94,10 @@ export function remapProjectState(
...board,
id: requiredId(boardIds, board.id),
projectId: targetProjectId,
floorId:
board.floorId === null
? null
: requiredId(floorIds, board.floorId),
})),
circuitLists: source.circuitLists.map((list) => ({
...list,
@@ -1,4 +1,5 @@
import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js";
import type { DistributionBoardUpdateProjectCommand } from "../models/distribution-board-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
@@ -23,4 +24,15 @@ export interface DistributionBoardStructureProjectCommandStore {
execute(
input: ExecuteDistributionBoardStructureCommandInput
): ExecutedDistributionBoardStructureCommand;
executeUpdate(
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardUpdateProjectCommand;
}
): {
revision: AppendedProjectRevision;
inverse: DistributionBoardUpdateProjectCommand;
};
}
@@ -37,6 +37,10 @@ import {
circuitDeleteCommandType,
circuitInsertCommandType,
} from "../models/circuit-structure-project-command.model.js";
import {
assertDistributionBoardUpdateProjectCommand,
distributionBoardUpdateCommandType,
} from "../models/distribution-board-project-command.model.js";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
@@ -285,6 +289,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case distributionBoardUpdateCommandType: {
assertDistributionBoardUpdateProjectCommand(input.command);
return this.distributionBoardStructureStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
@@ -1,7 +1,12 @@
"use client";
import { FormEvent, useState } from "react";
import React, { FormEvent, useState } from "react";
import type { ProjectDto } from "../types";
import {
distributionBoardSupplyTypeLabels,
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board";
export interface ProjectSettingsInput {
name: string;
@@ -11,6 +16,7 @@ export interface ProjectSettingsInput {
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
interface ProjectSettingsModalProps {
@@ -23,6 +29,7 @@ interface ProjectSettingsModalProps {
) => Promise<void>;
onSave: (input: ProjectSettingsInput) => Promise<void>;
project: ProjectDto;
usedDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
export function ProjectSettingsModal({
@@ -32,6 +39,7 @@ export function ProjectSettingsModal({
onImport,
onSave,
project,
usedDistributionBoardSupplyTypes,
}: ProjectSettingsModalProps) {
const [name, setName] = useState(project.name);
const [internalProjectNumber, setInternalProjectNumber] = useState(
@@ -52,6 +60,12 @@ export function ProjectSettingsModal({
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState(
String(project.threePhaseVoltageV)
);
const [
enabledDistributionBoardSupplyTypes,
setEnabledDistributionBoardSupplyTypes,
] = useState<DistributionBoardSupplyType[]>(
project.enabledDistributionBoardSupplyTypes
);
const [transfer, setTransfer] = useState<unknown>(null);
const [transferFilename, setTransferFilename] = useState("");
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
@@ -70,13 +84,31 @@ export function ProjectSettingsModal({
description: toNullableString(description),
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
enabledDistributionBoardSupplyTypes,
});
}
const isValid =
name.trim().length > 0 &&
Number(singlePhaseVoltageV) > 0 &&
Number(threePhaseVoltageV) > 0;
Number(threePhaseVoltageV) > 0 &&
enabledDistributionBoardSupplyTypes.length > 0;
function toggleSupplyType(supplyType: DistributionBoardSupplyType) {
if (
enabledDistributionBoardSupplyTypes.includes(supplyType) &&
usedDistributionBoardSupplyTypes.includes(supplyType)
) {
return;
}
setEnabledDistributionBoardSupplyTypes((current) =>
current.includes(supplyType)
? current.filter((entry) => entry !== supplyType)
: distributionBoardSupplyTypes.filter(
(entry) => entry === supplyType || current.includes(entry)
)
);
}
async function handleTransferFile(file: File | undefined) {
setTransfer(null);
@@ -165,6 +197,53 @@ export function ProjectSettingsModal({
value={externalProjectNumber}
/>
</div>
<div className="col-12">
<fieldset>
<legend className="form-label mb-1">
Verwendete Netzarten
</legend>
<p className="form-text mt-0">
Nur ausgewählte Netzarten stehen bei Verteilungen zur
Auswahl. Bereits verwendete Netzarten können nicht
deaktiviert werden.
</p>
<div className="row g-2">
{distributionBoardSupplyTypes.map((supplyType) => (
<div
className="col-12 col-md-6"
key={supplyType}
>
<label className="form-check">
<input
checked={enabledDistributionBoardSupplyTypes.includes(
supplyType
)}
className="form-check-input"
disabled={usedDistributionBoardSupplyTypes.includes(
supplyType
)}
onChange={() => toggleSupplyType(supplyType)}
type="checkbox"
/>
<span className="form-check-label">
{distributionBoardSupplyTypeLabels[supplyType]}
{usedDistributionBoardSupplyTypes.includes(
supplyType
)
? " (in Verwendung)"
: ""}
</span>
</label>
</div>
))}
</div>
{enabledDistributionBoardSupplyTypes.length === 0 ? (
<div className="text-danger small mt-2">
Mindestens eine Netzart muss aktiviert sein.
</div>
) : null}
</fieldset>
</div>
<div className="col-12">
<label className="form-label" htmlFor="building-owner">
Bauherr
+5
View File
@@ -1,3 +1,5 @@
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
export interface ProjectDto {
id: string;
name: string;
@@ -7,6 +9,7 @@ export interface ProjectDto {
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
currentRevision: number;
}
@@ -89,6 +92,8 @@ export interface DistributionBoardDto {
id: string;
projectId: string;
name: string;
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
}
export interface DistributionBoardCommandResultDto
+25 -2
View File
@@ -226,6 +226,7 @@ export function updateProjectSettings(
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
}
) {
return request<ProjectSettingsCommandResultDto>(
@@ -276,14 +277,36 @@ export function listDistributionBoards(projectId: string) {
export function createDistributionBoard(
projectId: string,
name: string,
input: {
name: string;
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
},
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
`/api/projects/${projectId}/distribution-boards`,
{
method: "POST",
body: JSON.stringify({ name, expectedRevision }),
body: JSON.stringify({ ...input, expectedRevision }),
}
);
}
export function updateDistributionBoard(
projectId: string,
distributionBoardId: string,
input: {
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
},
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
`/api/projects/${projectId}/distribution-boards/${distributionBoardId}`,
{
method: "PUT",
body: JSON.stringify({ ...input, expectedRevision }),
}
);
}
@@ -31,6 +31,7 @@ const commandTypeLabels: Record<string, string> = {
"project-device.sync-rows": "Projektgerät-Verknüpfungen geändert",
"project.update-settings": "Projekteinstellungen bearbeitet",
"distribution-board.insert": "Verteilung angelegt",
"distribution-board.update": "Verteilung bearbeitet",
"distribution-board.delete": "Verteilung entfernt",
"project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt",
@@ -1,9 +1,13 @@
import type { Request, Response } from "express";
import { createDistributionBoardUpdateProjectCommand } from "../../domain/models/distribution-board-project-command.model.js";
import {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
} from "../../domain/models/distribution-board-structure-project-command.model.js";
import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
import {
createDistributionBoardSchema,
updateDistributionBoardSchema,
} from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { distributionBoardRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
@@ -31,7 +35,11 @@ export async function createDistributionBoard(req: Request, res: Response) {
const structure = createDistributionBoardStructureSnapshot(
projectId,
parsed.data.name
parsed.data.name,
{
floorId: parsed.data.floorId,
supplyType: parsed.data.supplyType,
}
);
try {
const result = projectCommandService.executeUser({
@@ -49,3 +57,43 @@ export async function createDistributionBoard(req: Request, res: Response) {
return respondWithProjectCommandError(error, res);
}
}
export async function updateDistributionBoard(
req: Request,
res: Response
) {
const { projectId, distributionBoardId } = req.params;
if (
typeof projectId !== "string" ||
typeof distributionBoardId !== "string"
) {
return res.status(400).json({ error: "Invalid distribution board id" });
}
const parsed = updateDistributionBoardSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Verteilung bearbeiten",
command: createDistributionBoardUpdateProjectCommand(
distributionBoardId,
{
floorId: parsed.data.floorId,
supplyType: parsed.data.supplyType,
}
),
});
const distributionBoard = (
await distributionBoardRepository.listByProject(projectId)
).find((board) => board.id === distributionBoardId);
if (!distributionBoard) {
return res.status(404).json({ error: "Distribution board not found" });
}
return res.json({ ...result, distributionBoard });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
+5
View File
@@ -8,6 +8,7 @@ import {
import {
createDistributionBoard,
listDistributionBoardsByProject,
updateDistributionBoard,
} from "../controllers/distribution-board.controller.js";
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
@@ -55,6 +56,10 @@ projectRouter.post(
projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.put(
"/:projectId/distribution-boards/:distributionBoardId",
updateDistributionBoard
);
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
projectRouter.get("/:projectId/floors", listFloorsByProject);
@@ -0,0 +1,26 @@
export const distributionBoardSupplyTypes = [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
] as const;
export type DistributionBoardSupplyType =
(typeof distributionBoardSupplyTypes)[number];
export const defaultDistributionBoardSupplyTypes: DistributionBoardSupplyType[] =
[...distributionBoardSupplyTypes];
export const distributionBoardSupplyTypeLabels: Record<
DistributionBoardSupplyType,
string
> = {
AV: "AV Allgemeine Stromversorgung",
SV: "SV Sicherheitsstromversorgung",
EV: "EV Ersatzstromversorgung",
USV: "USV Unterbrechungsfreie Stromversorgung",
MSR: "MSR Mess-, Steuerungs- und Regelungstechnik",
SiBe: "SiBe Sicherheitsbeleuchtung",
};
@@ -1,4 +1,5 @@
import { z } from "zod";
import { distributionBoardSupplyTypes } from "../constants/distribution-board.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createProjectSchema = z.object({
@@ -21,6 +22,12 @@ export const updateProjectSettingsSchema = z
description: z.string().trim().max(2000).nullable(),
singlePhaseVoltageV: z.number().positive(),
threePhaseVoltageV: z.number().positive(),
enabledDistributionBoardSupplyTypes: z
.array(z.enum(distributionBoardSupplyTypes))
.min(1)
.refine((values) => new Set(values).size === values.length, {
message: "Netzarten dürfen nicht mehrfach ausgewählt werden.",
}),
})
.strict();
@@ -28,6 +35,16 @@ export const createDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1),
floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes),
})
.strict();
export const updateDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes),
})
.strict();