Add print view, board info card and document logo upload

- Seitenansicht (/projects/:projectId/print): tabular cover page, board
  overview with supply structure and power sums, one plan per board with a
  repeating title block; PDF via the browser print dialog with named @page
  rules (A4 portrait cover, A4/A3 landscape plans, page x of y).
- Distribution-board info card (board number, location, fed-from, remark)
  in the board edit dialog, persisted through the additively extended
  distribution-board.update command (migration 0008, snapshot schema 6,
  legacy subtree commands normalized).
- Global document logo upload (PNG/JPEG/SVG, max 2 MB) outside project
  revisions and snapshots.
- fed-from stays free text; the overview hierarchy and cumulated sums are
  preliminary until the feed link is modeled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-25 19:31:04 +02:00
parent 2e3e85a8e5
commit 4779e4a1a7
40 changed files with 5297 additions and 33 deletions

View file

@ -13,6 +13,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
export const circuitDeviceRowRepository =
new CircuitDeviceRowRepository(db);
@ -33,3 +34,4 @@ export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db);

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,84 @@
import type { Request, Response } from "express";
import type { DocumentBrandingAsset } from "../../db/schema/document-branding-assets.js";
import {
maxDocumentLogoBytes,
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 });
}
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

@ -1,6 +1,7 @@
import express from "express";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
@ -51,6 +52,7 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
app.use("/api/document-branding", documentBrandingRouter);
app.use(errorMiddleware);

View file

@ -0,0 +1,9 @@
import express from "express";
import * as documentBrandingController from "../controllers/document-branding.controller.js";
export const documentBrandingRouter = express.Router();
documentBrandingRouter.get("/", documentBrandingController.getDocumentBrandingHandler);
documentBrandingRouter.get("/logo", documentBrandingController.getDocumentLogoHandler);
documentBrandingRouter.put("/logo", documentBrandingController.uploadDocumentLogoHandler);
documentBrandingRouter.delete("/logo", documentBrandingController.deleteDocumentLogoHandler);