Merge remote-tracking branch 'origin/main' into feature/cable-sizing

This commit is contained in:
Julian Appel 2026-09-27 16:54:12 +02:00
commit 598003554a
136 changed files with 27182 additions and 1669 deletions

View file

@ -0,0 +1,111 @@
import type { Request, Response } from "express";
import {
createProjectBuildingDeleteProjectCommand,
createProjectBuildingInsertProjectCommand,
createProjectBuildingSnapshot,
createProjectBuildingUpdateProjectCommand,
} from "../../domain/models/project-location-structure-project-command.model.js";
import {
createBuildingSchema,
deleteProjectLocationSchema,
updateBuildingSchema,
} from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { buildingRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export async function listBuildingsByProject(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const result = await buildingRepository.listByProject(projectId);
return res.json(result);
}
export async function createBuilding(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = createBuildingSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const existingBuildings = await buildingRepository.listByProject(projectId);
const nextSortOrder = existingBuildings.length
? Math.max(...existingBuildings.map((building) => building.sortOrder)) + 1
: 0;
const building = createProjectBuildingSnapshot(
projectId,
parsed.data.name,
nextSortOrder
);
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude anlegen",
command: createProjectBuildingInsertProjectCommand(building),
});
return res.status(201).json({ ...result, building });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function updateBuilding(req: Request, res: Response) {
const { projectId, buildingId } = req.params;
if (typeof projectId !== "string" || typeof buildingId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = updateBuildingSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = await buildingRepository.findById(projectId, buildingId);
if (!current) {
return res.status(404).json({ error: "Building not found" });
}
const target = { ...current, name: parsed.data.name };
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude bearbeiten",
command: createProjectBuildingUpdateProjectCommand(current, target),
});
return res.json({ ...result, building: target });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function deleteBuilding(req: Request, res: Response) {
const { projectId, buildingId } = req.params;
if (typeof projectId !== "string" || typeof buildingId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = deleteProjectLocationSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = await buildingRepository.findById(projectId, buildingId);
if (!current) {
return res.status(404).json({ error: "Building not found" });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude löschen",
command: createProjectBuildingDeleteProjectCommand(current),
});
return res.json(result);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}

View file

@ -9,6 +9,7 @@ import {
createDistributionBoardInsertSubtreeProjectCommand,
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
import { cloneDistributionBoardSubtree } from "../../domain/models/distribution-board-subtree-snapshot.model.js";
import { distributionBoardInfoFields } from "../../shared/constants/distribution-board.js";
import {
copyDistributionBoardSchema,
createDistributionBoardSchema,
@ -64,6 +65,10 @@ export async function createDistributionBoard(req: Request, res: Response) {
distributionBoard: {
...structure.distributionBoard,
simultaneityFactor: 1,
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
},
});
} catch (error) {
@ -86,6 +91,13 @@ export async function updateDistributionBoard(
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
// Info fields are optional in the request; only submitted fields become
// part of the command and therefore of its inverse.
const infoPatch = Object.fromEntries(
distributionBoardInfoFields
.filter((field) => parsed.data[field] !== undefined)
.map((field) => [field, parsed.data[field]])
);
try {
const result = projectCommandService.executeUser({
projectId,
@ -97,6 +109,7 @@ export async function updateDistributionBoard(
floorId: parsed.data.floorId,
supplyType: parsed.data.supplyType,
simultaneityFactor: parsed.data.simultaneityFactor,
...infoPatch,
}
),
});

View file

@ -0,0 +1,100 @@
import type { Request, Response } from "express";
import type { DocumentBrandingAsset } from "../../db/schema/document-branding-assets.js";
import { officeProfileFields } from "../../shared/constants/project-cover.js";
import {
maxDocumentLogoBytes,
updateOfficeProfileSchema,
uploadDocumentLogoSchema,
type DocumentLogoMimeType,
} from "../../shared/validation/document-branding.schemas.js";
import { documentBrandingRepository } from "../composition/application-repositories.js";
const logoKey = "logo";
export function getDocumentBrandingHandler(_req: Request, res: Response) {
const logo = documentBrandingRepository.get(logoKey);
return res.json({
logo: logo ? toLogoMetadata(logo) : null,
office: documentBrandingRepository.getSettings(officeProfileFields),
});
}
export function updateOfficeProfileHandler(req: Request, res: Response) {
const parsed = updateOfficeProfileSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
documentBrandingRepository.setSettings(parsed.data);
return res.json({
office: documentBrandingRepository.getSettings(officeProfileFields),
});
}
export function getDocumentLogoHandler(_req: Request, res: Response) {
const logo = documentBrandingRepository.get(logoKey);
if (!logo) {
return res.status(404).json({ error: "No logo uploaded" });
}
res.setHeader("Content-Type", logo.mimeType);
res.setHeader("Cache-Control", "no-cache");
res.setHeader("X-Content-Type-Options", "nosniff");
// SVG uploads are rendered through <img> only; forbid script execution if
// the file is ever opened directly.
res.setHeader(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src data:"
);
return res.send(logo.data);
}
export function uploadDocumentLogoHandler(req: Request, res: Response) {
const parsed = uploadDocumentLogoSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const data = Buffer.from(parsed.data.contentBase64, "base64");
if (data.length === 0 || data.length > maxDocumentLogoBytes) {
return res
.status(400)
.json({ error: "Das Logo darf höchstens 2 MB groß sein." });
}
if (!matchesMimeType(data, parsed.data.mimeType)) {
return res.status(400).json({
error: "Der Dateiinhalt passt nicht zum angegebenen Bildformat.",
});
}
const logo = documentBrandingRepository.upsert({
key: logoKey,
mimeType: parsed.data.mimeType,
fileName: parsed.data.fileName,
data,
});
return res.json({ logo: toLogoMetadata(logo) });
}
export function deleteDocumentLogoHandler(_req: Request, res: Response) {
documentBrandingRepository.delete(logoKey);
return res.status(204).end();
}
function toLogoMetadata(logo: DocumentBrandingAsset) {
return {
mimeType: logo.mimeType,
fileName: logo.fileName,
sizeBytes: logo.data.length,
updatedAtIso: logo.updatedAt.toISOString(),
};
}
function matchesMimeType(data: Buffer, mimeType: DocumentLogoMimeType) {
if (mimeType === "image/png") {
return data
.subarray(0, 8)
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
}
if (mimeType === "image/jpeg") {
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
}
const head = data.subarray(0, 2048).toString("utf8").toLowerCase();
return head.includes("<svg");
}

View file

@ -0,0 +1,180 @@
import type { Request, Response } from "express";
import { ZodError } from "zod";
import {
createEstimateBalanceDeleteProjectCommand,
createEstimateBalanceInsertProjectCommand,
createEstimateBalanceSnapshot,
createEstimateBalanceUpdateProjectCommand,
parseEstimateBalanceSnapshot,
sameEstimateBalance,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import {
createEstimateBalanceRequestSchema,
deleteEstimateBalanceRequestSchema,
updateEstimateBalanceRequestSchema,
} from "../../estimate-balance/api/estimate-balance-request.schemas.js";
import { compareEstimateWithDetailed } from "../../shared/estimate-balance/estimate-balance.js";
import { estimateBalanceRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
function params(req: Request) {
const { projectId, balanceId } = req.params;
return {
projectId: typeof projectId === "string" ? projectId : null,
balanceId: typeof balanceId === "string" ? balanceId : null,
};
}
export function listEstimateBalances(req: Request, res: Response) {
const { projectId } = params(req);
if (!projectId) {
return res.status(400).json({ error: "Ungültige Projekt-ID." });
}
return res.json(estimateBalanceRepository.listByProject(projectId));
}
export function getEstimateBalance(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const balance = estimateBalanceRepository.findById(projectId, balanceId);
if (!balance) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
return res.json(balance);
}
export function createEstimateBalance(req: Request, res: Response) {
const { projectId } = params(req);
if (!projectId) {
return res.status(400).json({ error: "Ungültige Projekt-ID." });
}
const parsed = createEstimateBalanceRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
let template: Parameters<typeof createEstimateBalanceSnapshot>[0]["template"] =
parsed.data.template === "copy" ? "empty" : parsed.data.template;
if (parsed.data.template === "copy") {
const source = estimateBalanceRepository.findById(
projectId,
parsed.data.copyFromBalanceId!
);
if (!source) {
return res
.status(404)
.json({ error: "Der zu kopierende Stand wurde nicht gefunden." });
}
template = { copyFrom: source };
}
try {
const balance = createEstimateBalanceSnapshot({
projectId,
name: parsed.data.name,
workStage: parsed.data.workStage ?? null,
dateIso: parsed.data.dateIso ?? null,
sortOrder: estimateBalanceRepository.nextSortOrder(projectId),
template,
});
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Grobe Leistungsbilanz anlegen",
command: createEstimateBalanceInsertProjectCommand(balance),
});
return res.status(201).json({ ...result, balance });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function updateEstimateBalance(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const parsed = updateEstimateBalanceRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
let target;
try {
target = parseEstimateBalanceSnapshot(parsed.data.balance);
} catch (error) {
return res.status(400).json({
error: "Die grobe Leistungsbilanz enthält ungültige Werte.",
issues: error instanceof ZodError ? error.issues : undefined,
});
}
if (target.id !== balanceId || target.projectId !== projectId) {
return res
.status(400)
.json({ error: "Die Bilanz gehört nicht zu diesem Projekt oder dieser Adresse." });
}
const current = estimateBalanceRepository.findById(projectId, balanceId);
if (!current) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
if (sameEstimateBalance(current, target)) {
return res.json({ unchanged: true, balance: current });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Grobe Leistungsbilanz bearbeiten",
command: createEstimateBalanceUpdateProjectCommand(current, target),
});
return res.json({
...result,
balance: estimateBalanceRepository.findById(projectId, balanceId),
});
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function deleteEstimateBalance(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const parsed = deleteEstimateBalanceRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = estimateBalanceRepository.findById(projectId, balanceId);
if (!current) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Grobe Leistungsbilanz löschen",
command: createEstimateBalanceDeleteProjectCommand(current),
});
return res.json(result);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function getEstimateComparison(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const balance = estimateBalanceRepository.findById(projectId, balanceId);
if (!balance) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
return res.json(
compareEstimateWithDetailed(
balance,
estimateBalanceRepository.listDetailedDeviceRowPower(projectId)
)
);
}

View file

@ -42,7 +42,8 @@ export async function createFloor(req: Request, res: Response) {
const floor = createProjectFloorSnapshot(
projectId,
parsed.data.name,
nextSortOrder
nextSortOrder,
parsed.data.buildingId ?? null
);
try {
const result = projectCommandService.executeUser({
@ -70,7 +71,14 @@ export async function updateFloor(req: Request, res: Response) {
if (!current) {
return res.status(404).json({ error: "Floor not found" });
}
const target = { ...current, name: parsed.data.name };
const target = {
...current,
name: parsed.data.name,
buildingId:
parsed.data.buildingId === undefined
? current.buildingId
: parsed.data.buildingId,
};
try {
const result = projectCommandService.executeUser({
projectId,

View file

@ -4,6 +4,10 @@ import {
updateProjectSettingsSchema,
} from "../../shared/validation/project-structure.schemas.js";
import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js";
import {
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { projectRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
@ -46,12 +50,27 @@ export async function updateProjectSettings(req: Request, res: Response) {
}
const { expectedRevision, ...settings } = parsed.data;
const current = await projectRepository.findById(projectId);
if (!current) {
return res.status(404).json({ error: "Project not found" });
}
// Cover fields are optional in the request and keep their current value
// when omitted. `expectedRevision` rejects a project changed meanwhile.
const coverValues = Object.fromEntries(
projectCoverFields.map((field) => [
field,
settings[field] === undefined ? current[field] : settings[field],
])
) as Record<ProjectCoverField, string | null>;
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: "Projekteinstellungen bearbeiten",
command: createProjectSettingsUpdateProjectCommand(settings),
command: createProjectSettingsUpdateProjectCommand({
...settings,
...coverValues,
}),
});
const project = await projectRepository.findById(projectId);
if (!project) {