forked from jappel/leistungsbilanz-ts
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:
parent
2e3e85a8e5
commit
4779e4a1a7
40 changed files with 5297 additions and 33 deletions
|
|
@ -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,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
|
|
|||
84
src/server/controllers/document-branding.controller.ts
Normal file
84
src/server/controllers/document-branding.controller.ts
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue