594 lines
20 KiB
PHP
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
|
|
}
|