Files
Mitgliederverwaltung/lib/Controller/ImportController.php
T

594 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Controller;
use OCA\Mitgliederverwaltung\Service\BundleImportService;
use OCA\Mitgliederverwaltung\Service\EntityImportService;
use OCA\Mitgliederverwaltung\Service\ImportService;
use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* REST API controller for CSV/Excel import.
*
* Provides three import flows:
* - Legacy member import (3-step: upload/preview/execute) — Issue #58
* - Per-entity import with auto-detection — Issue #150
* - ZIP bundle import with dependency-aware ordering — Issue #151
*
* Admin-only access.
*/
class ImportController extends ApiController {
use ApiControllerTrait;
private ImportService $importService;
private EntityImportService $entityImportService;
private BundleImportService $bundleImportService;
private LoggerInterface $logger;
public function __construct(
string $appName,
IRequest $request,
ImportService $importService,
EntityImportService $entityImportService,
BundleImportService $bundleImportService,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->importService = $importService;
$this->entityImportService = $entityImportService;
$this->bundleImportService = $bundleImportService;
$this->logger = $logger;
}
/**
* Upload a CSV file and return parsed columns + preview rows.
*
* POST /api/v1/import/upload
*
* Expects JSON body:
* - content: string (Base64-encoded file content)
* - delimiter: string (optional, default ",")
* - encoding: string (optional, default "UTF-8")
*/
public function upload(): JSONResponse {
try {
$data = $this->getRequestData();
$content = $data['content'] ?? null;
if ($content === null || $content === '') {
return new JSONResponse(
['error' => 'Dateiinhalt ist erforderlich'],
Http::STATUS_BAD_REQUEST
);
}
// Decode Base64 content
$decoded = base64_decode($content, true);
if ($decoded === false) {
return new JSONResponse(
['error' => 'Ungueltige Base64-Kodierung'],
Http::STATUS_BAD_REQUEST
);
}
$delimiter = $data['delimiter'] ?? ',';
$encoding = $data['encoding'] ?? 'UTF-8';
$result = $this->importService->parseFile($decoded, $delimiter, $encoding);
// Also return target fields for the mapping step
$result['targetFields'] = $this->importService->getTargetFields();
return new JSONResponse($result);
} catch (ValidationException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
} catch (\Exception $e) {
$this->logger->error('Failed to parse import file', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Fehler beim Lesen der Datei'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/**
* Preview import with column mapping (dry-run).
*
* POST /api/v1/import/preview
*
* Expects JSON body:
* - content: string (Base64-encoded file content)
* - mapping: object { sourceIndex: targetFieldKey }
* - delimiter: string (optional)
* - encoding: string (optional)
* - groupFamilies: boolean (optional)
*/
public function preview(): JSONResponse {
try {
$data = $this->getRequestData();
$content = $data['content'] ?? null;
$mapping = $data['mapping'] ?? null;
if ($content === null || $mapping === null) {
return new JSONResponse(
['error' => 'content und mapping sind erforderlich'],
Http::STATUS_BAD_REQUEST
);
}
$decoded = base64_decode($content, true);
if ($decoded === false) {
return new JSONResponse(
['error' => 'Ungueltige Base64-Kodierung'],
Http::STATUS_BAD_REQUEST
);
}
$delimiter = $data['delimiter'] ?? ',';
$encoding = $data['encoding'] ?? 'UTF-8';
$groupFamilies = $data['groupFamilies'] ?? false;
$result = $this->importService->preview(
$decoded,
$mapping,
$delimiter,
$encoding,
$groupFamilies
);
return new JSONResponse($result);
} catch (ValidationException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
} catch (\Exception $e) {
$this->logger->error('Failed to preview import', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Internal server error'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/**
* Execute the import.
*
* POST /api/v1/import/execute
*
* Same body as preview.
*/
public function execute(): JSONResponse {
try {
$data = $this->getRequestData();
$content = $data['content'] ?? null;
$mapping = $data['mapping'] ?? null;
if ($content === null || $mapping === null) {
return new JSONResponse(
['error' => 'content und mapping sind erforderlich'],
Http::STATUS_BAD_REQUEST
);
}
$decoded = base64_decode($content, true);
if ($decoded === false) {
return new JSONResponse(
['error' => 'Ungueltige Base64-Kodierung'],
Http::STATUS_BAD_REQUEST
);
}
$delimiter = $data['delimiter'] ?? ',';
$encoding = $data['encoding'] ?? 'UTF-8';
$groupFamilies = $data['groupFamilies'] ?? false;
$result = $this->importService->execute(
$decoded,
$mapping,
$delimiter,
$encoding,
$groupFamilies
);
return new JSONResponse($result);
} catch (ValidationException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
} catch (\Exception $e) {
$this->logger->error('Failed to execute import', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Internal server error'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
// ── Per-entity import (Issue #150) ────────────────────────────
/**
* Upload a CSV file for entity import: parse, auto-detect type, return schema.
*
* POST /api/v1/import/entity/upload
* Body: { content, delimiter?, encoding?, entityType? }
*/
public function entityUpload(): JSONResponse {
try {
$data = $this->getRequestData();
$content = $data['content'] ?? null;
if ($content === null || $content === '') {
return new JSONResponse(
['error' => 'Dateiinhalt ist erforderlich'],
Http::STATUS_BAD_REQUEST
);
}
$decoded = base64_decode($content, true);
if ($decoded === false) {
return new JSONResponse(
['error' => 'Ungueltige Base64-Kodierung'],
Http::STATUS_BAD_REQUEST
);
}
$delimiter = $data['delimiter'] ?? ';';
$encoding = $data['encoding'] ?? 'UTF-8';
$parsed = $this->entityImportService->parseFile($decoded, $delimiter, $encoding);
// Auto-detect entity type
$detection = $this->entityImportService->detectEntityType($parsed['columns']);
// Use explicit type if provided, otherwise use detected
$entityType = $data['entityType'] ?? $detection['type'];
$result = [
'columns' => $parsed['columns'],
'rows' => $parsed['rows'],
'totalRows' => $parsed['totalRows'],
'detection' => $detection,
'entityType' => $entityType,
];
// If we have a type, include schema and auto-mapping
if ($entityType) {
$schema = $this->entityImportService->getImportSchema($entityType);
$result['targetFields'] = $schema['targetFields'];
$result['mapping'] = $this->entityImportService->autoMapHeaders($entityType, $parsed['columns']);
}
return new JSONResponse($result);
} catch (ValidationException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
} catch (\Exception $e) {
$this->logger->error('Failed to parse entity import file', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Fehler beim Lesen der Datei'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/**
* Get import schema for an entity type (target fields, headers).
*
* GET /api/v1/import/entity/schema/{type}
*/
public function entitySchema(string $type): JSONResponse {
try {
$schema = $this->entityImportService->getImportSchema($type);
return new JSONResponse($schema);
} catch (\InvalidArgumentException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
}
}
/**
* Preview entity import (dry-run).
*
* POST /api/v1/import/entity/preview
* Body: { content, entityType, mapping, delimiter?, encoding? }
*/
public function entityPreview(): JSONResponse {
try {
$data = $this->getRequestData();
$content = $data['content'] ?? null;
$entityType = $data['entityType'] ?? null;
$mapping = $data['mapping'] ?? null;
if ($content === null || $entityType === null || $mapping === null) {
return new JSONResponse(
['error' => 'content, entityType und mapping sind erforderlich'],
Http::STATUS_BAD_REQUEST
);
}
$decoded = base64_decode($content, true);
if ($decoded === false) {
return new JSONResponse(
['error' => 'Ungueltige Base64-Kodierung'],
Http::STATUS_BAD_REQUEST
);
}
$delimiter = $data['delimiter'] ?? ';';
$encoding = $data['encoding'] ?? 'UTF-8';
$migrationMode = $data['migrationMode'] ?? false;
$corrections = $data['corrections'] ?? [];
$result = $this->entityImportService->preview(
$entityType,
$decoded,
$mapping,
$delimiter,
$encoding,
(bool)$migrationMode,
$corrections
);
return new JSONResponse($result);
} catch (\Exception $e) {
$this->logger->error('Failed to preview entity import', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Fehler bei der Vorschau'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/**
* Execute entity import.
*
* POST /api/v1/import/entity/execute
* Body: { content, entityType, mapping, delimiter?, encoding? }
*/
public function entityExecute(): JSONResponse {
try {
$data = $this->getRequestData();
$content = $data['content'] ?? null;
$entityType = $data['entityType'] ?? null;
$mapping = $data['mapping'] ?? null;
if ($content === null || $entityType === null || $mapping === null) {
return new JSONResponse(
['error' => 'content, entityType und mapping sind erforderlich'],
Http::STATUS_BAD_REQUEST
);
}
$decoded = base64_decode($content, true);
if ($decoded === false) {
return new JSONResponse(
['error' => 'Ungueltige Base64-Kodierung'],
Http::STATUS_BAD_REQUEST
);
}
$delimiter = $data['delimiter'] ?? ';';
$encoding = $data['encoding'] ?? 'UTF-8';
$migrationMode = $data['migrationMode'] ?? false;
$corrections = $data['corrections'] ?? [];
$result = $this->entityImportService->execute(
$entityType,
$decoded,
$mapping,
$delimiter,
$encoding,
(bool)$migrationMode,
$corrections
);
return new JSONResponse($result);
} catch (\Exception $e) {
$this->logger->error('Failed to execute entity import', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Import fehlgeschlagen'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
// ── Conflict resolution (Issue #152) ─────────────────────────
/**
* Apply per-field merge resolutions for duplicate records.
*
* POST /api/v1/import/entity/merge
* Body: { entityType, resolutions: [{existingId, mergedFields: {field: value}}] }
*/
public function entityMerge(): JSONResponse {
try {
$data = $this->getRequestData();
$entityType = $data['entityType'] ?? null;
$resolutions = $data['resolutions'] ?? null;
if ($entityType === null || $resolutions === null) {
return new JSONResponse(
['error' => 'entityType und resolutions sind erforderlich'],
Http::STATUS_BAD_REQUEST
);
}
$result = $this->entityImportService->mergeConflicts($entityType, $resolutions);
return new JSONResponse($result);
} catch (\Exception $e) {
$this->logger->error('Failed to merge conflicts', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Konfliktzusammenfuehrung fehlgeschlagen'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
// ── ZIP bundle import (Issue #151) ────────────────────────────
/**
* Upload and analyze a ZIP bundle.
*
* POST /api/v1/import/bundle/upload
* Body: { content (base64) }
*/
public function bundleUpload(): JSONResponse {
try {
$data = $this->getRequestData();
$content = $data['content'] ?? null;
if ($content === null || $content === '') {
return new JSONResponse(
['error' => 'Dateiinhalt ist erforderlich'],
Http::STATUS_BAD_REQUEST
);
}
$decoded = base64_decode($content, true);
if ($decoded === false) {
return new JSONResponse(
['error' => 'Ungueltige Base64-Kodierung'],
Http::STATUS_BAD_REQUEST
);
}
$analysis = $this->bundleImportService->analyzeBundle($decoded);
return new JSONResponse($analysis);
} catch (ValidationException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
} catch (\Exception $e) {
$this->logger->error('Failed to analyze bundle', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Bundle-Analyse fehlgeschlagen'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/**
* Preview bundle import (dry-run for all entities).
*
* POST /api/v1/import/bundle/preview
* Body: { content (base64) }
*/
public function bundlePreview(): JSONResponse {
try {
$data = $this->getRequestData();
$content = $data['content'] ?? null;
if ($content === null) {
return new JSONResponse(
['error' => 'content ist erforderlich'],
Http::STATUS_BAD_REQUEST
);
}
$decoded = base64_decode($content, true);
if ($decoded === false) {
return new JSONResponse(
['error' => 'Ungueltige Base64-Kodierung'],
Http::STATUS_BAD_REQUEST
);
}
$result = $this->bundleImportService->previewBundle($decoded);
return new JSONResponse($result);
} catch (\Exception $e) {
$this->logger->error('Failed to preview bundle', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Bundle-Vorschau fehlgeschlagen'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/**
* Execute bundle import.
*
* POST /api/v1/import/bundle/execute
* Body: { content (base64), overrides?: {filename: type} }
*/
public function bundleExecute(): JSONResponse {
try {
$data = $this->getRequestData();
$content = $data['content'] ?? null;
if ($content === null) {
return new JSONResponse(
['error' => 'content ist erforderlich'],
Http::STATUS_BAD_REQUEST
);
}
$decoded = base64_decode($content, true);
if ($decoded === false) {
return new JSONResponse(
['error' => 'Ungueltige Base64-Kodierung'],
Http::STATUS_BAD_REQUEST
);
}
$overrides = $data['overrides'] ?? [];
$result = $this->bundleImportService->executeBundle($decoded, $overrides);
return new JSONResponse($result);
} catch (\Exception $e) {
$this->logger->error('Failed to execute bundle import', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Bundle-Import fehlgeschlagen'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
// getRequestData() provided by ApiControllerTrait
}