diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 13afbe9..206f967 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -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 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. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 187d6ed..7c77bee 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -502,6 +502,13 @@ Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3, Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren werden Konfigurations-UUID und Projektlink remapped. Importvorschau, persistente 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 diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index f677e26..b99729b 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -249,9 +249,11 @@ keine Projektrevision und laufen über einen eigenen Audit-Store. synthetische Testdaten; die Referenzdatei steht noch aus.** 3. Konfiguration über Migration, Snapshot v3, Transfer und den Command `external-csv-configuration.update` persistieren. **Erledigt.** -4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. Der Server speichert - keinen Entwurf: Bei Bestätigung wird die Datei erneut übertragen, erneut - geparst und ihr Hash gegen die Vorschau geprüft. +4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:** + Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt. **Offen:** + 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 Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit diff --git a/scripts/verify-revit-reference-csv.ts b/scripts/verify-revit-reference-csv.ts index 7d4f047..07d605b 100644 --- a/scripts/verify-revit-reference-csv.ts +++ b/scripts/verify-revit-reference-csv.ts @@ -7,6 +7,7 @@ import { parseExternalCsv, serializeExternalCsv, } from "../src/external-model/csv/external-csv-transport.js"; +import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; const filePath = resolve( 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 !== "") ).length; const serialized = serializeExternalCsv(document); +const preview = createExternalCsvPreview({ + fileName: filePath, + bytes: source, + configuration, +}); 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(counts["suspect-object"], 0, "Expected no suspect object rows."); assert.equal( @@ -66,6 +73,7 @@ process.stdout.write( rows: counts, nonEmptyPassthroughRows, roundTripByteIdentical: true, + previewObjectCount: preview.objectCount, }, null, 2 diff --git a/src/db/repositories/external-csv-configuration.repository.ts b/src/db/repositories/external-csv-configuration.repository.ts new file mode 100644 index 0000000..2b39a65 --- /dev/null +++ b/src/db/repositories/external-csv-configuration.repository.ts @@ -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 }; + } +} diff --git a/src/domain/ports/external-csv-configuration.reader.ts b/src/domain/ports/external-csv-configuration.reader.ts new file mode 100644 index 0000000..a50144b --- /dev/null +++ b/src/domain/ports/external-csv-configuration.reader.ts @@ -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; +} diff --git a/src/external-model/application/external-csv-preview.ts b/src/external-model/application/external-csv-preview.ts new file mode 100644 index 0000000..57aa480 --- /dev/null +++ b/src/external-model/application/external-csv-preview.ts @@ -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; +} + +export interface ExternalCsvPreview { + fileName: string; + byteLength: number; + sha256: string; + dialect: ReturnType["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), + }; +} diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts index a880b41..ebeaf5f 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -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); diff --git a/src/server/controllers/external-csv.controller.ts b/src/server/controllers/external-csv.controller.ts new file mode 100644 index 0000000..592bcd0 --- /dev/null +++ b/src/server/controllers/external-csv.controller.ts @@ -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; +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index b0bd39f..89909d0 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -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); diff --git a/src/shared/validation/external-csv.schemas.ts b/src/shared/validation/external-csv.schemas.ts new file mode 100644 index 0000000..5650b09 --- /dev/null +++ b/src/shared/validation/external-csv.schemas.ts @@ -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(); diff --git a/tests/external-csv-api-contract.test.ts b/tests/external-csv-api-contract.test.ts new file mode 100644 index 0000000..261f962 --- /dev/null +++ b/tests/external-csv-api-contract.test.ts @@ -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 + ); + }); +}); diff --git a/tests/external-csv-configuration-project-command.repository.test.ts b/tests/external-csv-configuration-project-command.repository.test.ts index c3e9c97..0186581 100644 --- a/tests/external-csv-configuration-project-command.repository.test.ts +++ b/tests/external-csv-configuration-project-command.repository.test.ts @@ -8,6 +8,7 @@ import { type DatabaseContext, } from "../src/db/database-context.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 { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.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", () => { const context = createTestDatabase(); try { diff --git a/tests/external-csv-preview.test.ts b/tests/external-csv-preview.test.ts new file mode 100644 index 0000000..b786ebd --- /dev/null +++ b/tests/external-csv-preview.test.ts @@ -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" }, + }); + }); +});