forked from jappel/leistungsbilanz-ts
Add Revit CSV preview API
This commit is contained in:
parent
219da5ec3b
commit
caa6caf99f
14 changed files with 437 additions and 3 deletions
28
src/db/repositories/external-csv-configuration.repository.ts
Normal file
28
src/db/repositories/external-csv-configuration.repository.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
|
||||
export class ExternalCsvConfigurationRepository
|
||||
implements ExternalCsvConfigurationReader
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
getByProject(projectId: string) {
|
||||
const project = this.database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
return { projectExists: false, configuration: null };
|
||||
}
|
||||
const configuration = this.database
|
||||
.select()
|
||||
.from(externalCsvConfigurations)
|
||||
.where(eq(externalCsvConfigurations.projectId, projectId))
|
||||
.get() ?? null;
|
||||
return { projectExists: true, configuration };
|
||||
}
|
||||
}
|
||||
10
src/domain/ports/external-csv-configuration.reader.ts
Normal file
10
src/domain/ports/external-csv-configuration.reader.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { ExternalCsvConfigurationSnapshot } from "../models/external-csv-configuration-project-command.model.js";
|
||||
|
||||
export interface ExternalCsvConfigurationReadResult {
|
||||
projectExists: boolean;
|
||||
configuration: ExternalCsvConfigurationSnapshot | null;
|
||||
}
|
||||
|
||||
export interface ExternalCsvConfigurationReader {
|
||||
getByProject(projectId: string): ExternalCsvConfigurationReadResult;
|
||||
}
|
||||
91
src/external-model/application/external-csv-preview.ts
Normal file
91
src/external-model/application/external-csv-preview.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
|
||||
import { parseExternalCsv } from "../csv/external-csv-transport.js";
|
||||
|
||||
export interface ExternalCsvPreviewObject {
|
||||
rowNumber: number;
|
||||
ifcGuid: string;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
familyAndType: string;
|
||||
selectionMarker: string;
|
||||
circuitIdentifier: string;
|
||||
power: string;
|
||||
quantity: string | null;
|
||||
additionalSourceValues: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ExternalCsvPreview {
|
||||
fileName: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
dialect: ReturnType<typeof parseExternalCsv>["dialect"];
|
||||
headerRowNumber: number;
|
||||
headerColumns: string[];
|
||||
rowCount: number;
|
||||
objectCount: number;
|
||||
passthroughCount: number;
|
||||
nonEmptyPassthroughCount: number;
|
||||
suspectObjectCount: number;
|
||||
objects: ExternalCsvPreviewObject[];
|
||||
suspectRowNumbers: number[];
|
||||
}
|
||||
|
||||
export function createExternalCsvPreview(input: {
|
||||
fileName: string;
|
||||
bytes: Uint8Array;
|
||||
configuration: ExternalCsvConfiguration;
|
||||
}): ExternalCsvPreview {
|
||||
const document = parseExternalCsv(input.bytes, input.configuration);
|
||||
const header = document.rows[document.headerRowIndex];
|
||||
const headerIndex = new Map(
|
||||
header.cells.map((cell, index) => [cell.value, index])
|
||||
);
|
||||
const valueAt = (row: (typeof document.rows)[number], column: string) =>
|
||||
row.cells[headerIndex.get(column)!]?.value ?? "";
|
||||
const objects = document.rows
|
||||
.filter((row) => row.classification === "object")
|
||||
.map((row): ExternalCsvPreviewObject => ({
|
||||
rowNumber: row.index + 1,
|
||||
ifcGuid: valueAt(row, input.configuration.columns.ifcGuid),
|
||||
roomNumber: valueAt(row, input.configuration.columns.roomNumber),
|
||||
roomName: valueAt(row, input.configuration.columns.roomName),
|
||||
familyAndType: valueAt(row, input.configuration.columns.familyAndType),
|
||||
selectionMarker: valueAt(row, input.configuration.columns.selectionMarker),
|
||||
circuitIdentifier: valueAt(row, input.configuration.columns.circuitIdentifier),
|
||||
power: valueAt(row, input.configuration.columns.power),
|
||||
quantity:
|
||||
input.configuration.columns.quantity === null
|
||||
? null
|
||||
: valueAt(row, input.configuration.columns.quantity),
|
||||
additionalSourceValues: Object.fromEntries(
|
||||
input.configuration.additionalSourceMappings.map((mapping) => [
|
||||
mapping.targetField,
|
||||
valueAt(row, mapping.sourceColumn),
|
||||
])
|
||||
),
|
||||
}));
|
||||
const passthroughRows = document.rows.filter(
|
||||
(row) => row.classification === "passthrough"
|
||||
);
|
||||
const suspectRows = document.rows.filter(
|
||||
(row) => row.classification === "suspect-object"
|
||||
);
|
||||
return {
|
||||
fileName: input.fileName,
|
||||
byteLength: input.bytes.byteLength,
|
||||
sha256: createHash("sha256").update(input.bytes).digest("hex"),
|
||||
dialect: document.dialect,
|
||||
headerRowNumber: document.headerRowIndex + 1,
|
||||
headerColumns: header.cells.map((cell) => cell.value),
|
||||
rowCount: document.rows.length,
|
||||
objectCount: objects.length,
|
||||
passthroughCount: passthroughRows.length,
|
||||
nonEmptyPassthroughCount: passthroughRows.filter((row) =>
|
||||
row.cells.some((cell) => cell.value !== "")
|
||||
).length,
|
||||
suspectObjectCount: suspectRows.length,
|
||||
objects,
|
||||
suspectRowNumbers: suspectRows.map((row) => row.index + 1),
|
||||
};
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { GlobalDeviceRepository } from "../../db/repositories/global-device.repo
|
|||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||
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";
|
||||
|
||||
export const circuitDeviceRowRepository =
|
||||
new CircuitDeviceRowRepository(db);
|
||||
|
|
@ -28,3 +29,5 @@ export const globalDeviceRepository = new GlobalDeviceRepository(db);
|
|||
export const projectDeviceRepository = new ProjectDeviceRepository(db);
|
||||
export const projectRepository = new ProjectRepository(db);
|
||||
export const roomRepository = new RoomRepository(db);
|
||||
export const externalCsvConfigurationRepository =
|
||||
new ExternalCsvConfigurationRepository(db);
|
||||
|
|
|
|||
117
src/server/controllers/external-csv.controller.ts
Normal file
117
src/server/controllers/external-csv.controller.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import crypto from "node:crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
||||
import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js";
|
||||
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
|
||||
import { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js";
|
||||
import {
|
||||
previewExternalCsvSchema,
|
||||
updateExternalCsvConfigurationSchema,
|
||||
} from "../../shared/validation/external-csv.schemas.js";
|
||||
import { externalCsvConfigurationRepository } from "../composition/application-repositories.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
||||
export function getExternalCsvConfiguration(req: Request, res: Response) {
|
||||
const projectId = getProjectId(req, res);
|
||||
if (!projectId) return;
|
||||
const result = externalCsvConfigurationRepository.getByProject(projectId);
|
||||
if (!result.projectExists) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
return res.json({ configuration: result.configuration });
|
||||
}
|
||||
|
||||
export function updateExternalCsvConfiguration(req: Request, res: Response) {
|
||||
const projectId = getProjectId(req, res);
|
||||
if (!projectId) return;
|
||||
const parsed = updateExternalCsvConfigurationSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
try {
|
||||
assertExternalCsvConfiguration(parsed.data.configuration);
|
||||
const current = externalCsvConfigurationRepository.getByProject(projectId);
|
||||
if (!current.projectExists) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
const target = {
|
||||
id: current.configuration?.id ?? crypto.randomUUID(),
|
||||
projectId,
|
||||
configurationVersion:
|
||||
(current.configuration?.configurationVersion ?? 0) + 1,
|
||||
configuration: parsed.data.configuration,
|
||||
};
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Revit-CSV-Konfiguration bearbeiten",
|
||||
command: createExternalCsvConfigurationUpdateProjectCommand(
|
||||
current.configuration,
|
||||
target
|
||||
),
|
||||
});
|
||||
return res.json({ ...result, configuration: target });
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
export function previewExternalCsv(req: Request, res: Response) {
|
||||
const projectId = getProjectId(req, res);
|
||||
if (!projectId) return;
|
||||
const parsed = previewExternalCsvSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const stored = externalCsvConfigurationRepository.getByProject(projectId);
|
||||
if (!stored.projectExists) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
if (!stored.configuration) {
|
||||
return res.status(409).json({
|
||||
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
|
||||
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const bytes = decodeBase64(parsed.data.contentBase64);
|
||||
return res.json(
|
||||
createExternalCsvPreview({
|
||||
fileName: parsed.data.fileName,
|
||||
bytes,
|
||||
configuration: stored.configuration.configuration,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalCsvParseError) {
|
||||
return res.status(400).json({ error: error.message, code: error.code });
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: error instanceof Error ? error.message : "CSV-Vorschau fehlgeschlagen.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64(value: string) {
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) {
|
||||
throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert.");
|
||||
}
|
||||
const bytes = Buffer.from(value, "base64");
|
||||
if (bytes.toString("base64") !== value) {
|
||||
throw new Error("CSV-Inhalt ist nicht kanonisch Base64-kodiert.");
|
||||
}
|
||||
if (bytes.length > 18 * 1024 * 1024) {
|
||||
throw new Error("CSV-Datei überschreitet die maximale Größe von 18 MB.");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function getProjectId(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||
res.status(400).json({ error: "Invalid projectId" });
|
||||
return null;
|
||||
}
|
||||
return projectId;
|
||||
}
|
||||
|
|
@ -35,6 +35,11 @@ import {
|
|||
importProjectTransfer,
|
||||
importProjectTransferAsNewProject,
|
||||
} from "../controllers/project-transfer.controller.js";
|
||||
import {
|
||||
getExternalCsvConfiguration,
|
||||
previewExternalCsv,
|
||||
updateExternalCsvConfiguration,
|
||||
} from "../controllers/external-csv.controller.js";
|
||||
|
||||
export const projectRouter = Router();
|
||||
|
||||
|
|
@ -44,6 +49,9 @@ projectRouter.post("/import", importProjectTransferAsNewProject);
|
|||
projectRouter.get("/:projectId", getProject);
|
||||
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||
projectRouter.post("/:projectId/import", importProjectTransfer);
|
||||
projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration);
|
||||
projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration);
|
||||
projectRouter.post("/:projectId/external-csv/preview", previewExternalCsv);
|
||||
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||
|
|
|
|||
15
src/shared/validation/external-csv.schemas.ts
Normal file
15
src/shared/validation/external-csv.schemas.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const updateExternalCsvConfigurationSchema = z
|
||||
.object({
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
configuration: z.unknown(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const previewExternalCsvSchema = z
|
||||
.object({
|
||||
fileName: z.string().trim().min(1).max(255),
|
||||
contentBase64: z.string().min(1).max(24_000_000),
|
||||
})
|
||||
.strict();
|
||||
Loading…
Add table
Add a link
Reference in a new issue