Add Revit CSV preview API

This commit is contained in:
2026-08-02 16:49:42 +02:00
parent 219da5ec3b
commit caa6caf99f
14 changed files with 437 additions and 3 deletions
+19
View File
@@ -389,3 +389,22 @@ inverse command. The former direct restore/reconnect endpoints are removed.
`displayName` is included in the comparison but is not selected by default in the UI. Updating a `displayName` is included in the comparison but is not selected by default in the UI. Updating a
project device never triggers synchronization implicitly. project device never triggers synchronization implicitly.
## Revit CSV Foundation
- `GET /projects/:projectId/external-csv/configuration`
- returns the complete versioned project configuration or `null`
- `PUT /projects/:projectId/external-csv/configuration`
- request: `expectedRevision` and the complete validated configuration
- executes `external-csv-configuration.update`
- returns configuration, revision and project history state
- `POST /projects/:projectId/external-csv/preview`
- request: file name and Base64-encoded CSV content up to 18 MB
- requires a stored project configuration
- returns transport metadata, SHA-256, row classifications, suspect row
numbers and mapped source values for every recognized IFC object
- does not persist a draft, mutate project state or create a revision
Parser errors expose stable codes such as `header-not-found`,
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. A later
confirmed import must upload and parse the file again and verify its hash.
+7
View File
@@ -502,6 +502,13 @@ Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3,
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
werden Konfigurations-UUID und Projektlink remapped. Importvorschau, persistente werden Konfigurations-UUID und Projektlink remapped. Importvorschau, persistente
externe Objekte und die Revit-GUI sind noch nicht implementiert. externe Objekte und die Revit-GUI sind noch nicht implementiert.
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command.
`POST /api/projects/:projectId/external-csv/preview` nimmt Dateiname und
Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash,
Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte
Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten.
## Noch nicht unterstützt ## Noch nicht unterstützt
@@ -249,9 +249,11 @@ keine Projektrevision und laufen über einen eigenen Audit-Store.
synthetische Testdaten; die Referenzdatei steht noch aus.** synthetische Testdaten; die Referenzdatei steht noch aus.**
3. Konfiguration über Migration, Snapshot v3, Transfer und den Command 3. Konfiguration über Migration, Snapshot v3, Transfer und den Command
`external-csv-configuration.update` persistieren. **Erledigt.** `external-csv-configuration.update` persistieren. **Erledigt.**
4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. Der Server speichert 4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:**
keinen Entwurf: Bei Bestätigung wird die Datei erneut übertragen, erneut Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt. **Offen:**
geparst und ihr Hash gegen die Vorschau geprüft. deutscher Projekt-Wizard. Der Server speichert keinen Entwurf: Bei
Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash
gegen die Vorschau geprüft.
Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen
Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit
+8
View File
@@ -7,6 +7,7 @@ import {
parseExternalCsv, parseExternalCsv,
serializeExternalCsv, serializeExternalCsv,
} from "../src/external-model/csv/external-csv-transport.js"; } from "../src/external-model/csv/external-csv-transport.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
const filePath = resolve( const filePath = resolve(
process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv" process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv"
@@ -45,8 +46,14 @@ const nonEmptyPassthroughRows = document.rows.filter(
row.cells.some((cell) => cell.value !== "") row.cells.some((cell) => cell.value !== "")
).length; ).length;
const serialized = serializeExternalCsv(document); const serialized = serializeExternalCsv(document);
const preview = createExternalCsvPreview({
fileName: filePath,
bytes: source,
configuration,
});
assert.equal(counts.object, 897, "Expected 897 object rows."); assert.equal(counts.object, 897, "Expected 897 object rows.");
assert.equal(preview.objectCount, 897, "Expected 897 preview objects.");
assert.equal(nonEmptyPassthroughRows, 211, "Expected 211 non-empty passthrough rows."); assert.equal(nonEmptyPassthroughRows, 211, "Expected 211 non-empty passthrough rows.");
assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows."); assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows.");
assert.equal( assert.equal(
@@ -66,6 +73,7 @@ process.stdout.write(
rows: counts, rows: counts,
nonEmptyPassthroughRows, nonEmptyPassthroughRows,
roundTripByteIdentical: true, roundTripByteIdentical: true,
previewObjectCount: preview.objectCount,
}, },
null, null,
2 2
@@ -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 };
}
}
@@ -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;
}
@@ -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 { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import { ProjectRepository } from "../../db/repositories/project.repository.js"; import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js"; import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
export const circuitDeviceRowRepository = export const circuitDeviceRowRepository =
new CircuitDeviceRowRepository(db); new CircuitDeviceRowRepository(db);
@@ -28,3 +29,5 @@ export const globalDeviceRepository = new GlobalDeviceRepository(db);
export const projectDeviceRepository = new ProjectDeviceRepository(db); export const projectDeviceRepository = new ProjectDeviceRepository(db);
export const projectRepository = new ProjectRepository(db); export const projectRepository = new ProjectRepository(db);
export const roomRepository = new RoomRepository(db); export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db);
@@ -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;
}
+8
View File
@@ -35,6 +35,11 @@ import {
importProjectTransfer, importProjectTransfer,
importProjectTransferAsNewProject, importProjectTransferAsNewProject,
} from "../controllers/project-transfer.controller.js"; } from "../controllers/project-transfer.controller.js";
import {
getExternalCsvConfiguration,
previewExternalCsv,
updateExternalCsvConfiguration,
} from "../controllers/external-csv.controller.js";
export const projectRouter = Router(); export const projectRouter = Router();
@@ -44,6 +49,9 @@ projectRouter.post("/import", importProjectTransferAsNewProject);
projectRouter.get("/:projectId", getProject); projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/export", exportProjectTransfer); projectRouter.get("/:projectId/export", exportProjectTransfer);
projectRouter.post("/:projectId/import", importProjectTransfer); 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", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions); projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand); projectRouter.post("/:projectId/commands", executeProjectCommand);
@@ -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();
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
previewExternalCsvSchema,
updateExternalCsvConfigurationSchema,
} from "../src/shared/validation/external-csv.schemas.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
describe("external CSV API contracts", () => {
it("accepts strict update and preview request envelopes", () => {
assert.equal(
updateExternalCsvConfigurationSchema.safeParse({
expectedRevision: 4,
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
}).success,
true
);
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
}).success,
true
);
});
it("rejects stale-shaped, oversized and unknown request fields", () => {
assert.equal(
updateExternalCsvConfigurationSchema.safeParse({
expectedRevision: -1,
configuration: {},
}).success,
false
);
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
apply: true,
}).success,
false
);
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "a".repeat(24_000_001),
}).success,
false
);
});
});
@@ -8,6 +8,7 @@ import {
type DatabaseContext, type DatabaseContext,
} from "../src/db/database-context.js"; } from "../src/db/database-context.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js"; import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
import { ExternalCsvConfigurationRepository } from "../src/db/repositories/external-csv-configuration.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
@@ -169,6 +170,23 @@ describe("external CSV configuration project command", () => {
} }
}); });
it("distinguishes projects without configuration from unknown projects", () => {
const context = createTestDatabase();
try {
const reader = new ExternalCsvConfigurationRepository(context.db);
assert.deepEqual(reader.getByProject("project-1"), {
projectExists: true,
configuration: null,
});
assert.deepEqual(reader.getByProject("missing"), {
projectExists: false,
configuration: null,
});
} finally {
context.close();
}
});
it("restores and undoes the complete configuration through snapshot history", () => { it("restores and undoes the complete configuration through snapshot history", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
+56
View File
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
import {
externalCsvTestConfiguration,
firstIfcGuid,
quotedRevitCsv,
utf8Bytes,
} from "./fixtures/revit-csv-fixtures.js";
describe("external CSV preview", () => {
it("projects transport statistics and mapped object source values without writes", () => {
const configuration = structuredClone(externalCsvTestConfiguration);
configuration.additionalSourceMappings.push({
sourceColumn: "Raumname",
targetField: "sourceRoomLabel",
});
// The additional mapping intentionally needs a distinct source column in
// persisted configuration, so use a separately named synthetic header.
configuration.additionalSourceMappings[0].sourceColumn = "Zusatzwert";
const csv = quotedRevitCsv
.replace('"Anzahl";"IfcGUID"', '"Anzahl";"Zusatzwert";"IfcGUID"')
.replaceAll(';"2";"0Ab', ';"2";"A";"0Ab')
.replaceAll(';"1";"1Ab', ';"1";"B";"1Ab')
.replaceAll(';"1";""', ';"1";"C";""')
.replaceAll(';"";""\r\n', ';"";"";""\r\n');
const preview = createExternalCsvPreview({
fileName: "export.csv",
bytes: utf8Bytes(csv, true),
configuration,
});
assert.equal(preview.fileName, "export.csv");
assert.equal(preview.headerRowNumber, 2);
assert.equal(preview.rowCount, 7);
assert.equal(preview.objectCount, 2);
assert.equal(preview.passthroughCount, 2);
assert.equal(preview.nonEmptyPassthroughCount, 1);
assert.equal(preview.suspectObjectCount, 1);
assert.deepEqual(preview.suspectRowNumbers, [6]);
assert.match(preview.sha256, /^[a-f0-9]{64}$/);
assert.deepEqual(preview.objects[0], {
rowNumber: 4,
ifcGuid: firstIfcGuid,
roomNumber: "01/101",
roomName: "Technik",
familyAndType: "Steckdose: Doppelsteckdose",
selectionMarker: "Arbeitsplatz",
circuitIdentifier: "UV_AV_01-2F1",
power: "120,5",
quantity: "2",
additionalSourceValues: { sourceRoomLabel: "A" },
});
});
});