Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
160 / 160 |
|
100.00% |
9 / 9 |
CRAP | |
100.00% |
1 / 1 |
| DataExchangeHtmxController | |
100.00% |
159 / 159 |
|
100.00% |
9 / 9 |
21 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| actionExportModal | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| actionExportDownload | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
4 | |||
| actionImportModal | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| actionUploadAndAnalyze | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
3 | |||
| actionStartImport | |
100.00% |
49 / 49 |
|
100.00% |
1 / 1 |
4 | |||
| actionImportProgress | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
5 | |||
| createImportJobRecord | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
| htmlResponse | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\DataExchange\Presentation\Htmx; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\DataExchange\Application\Service\DataExportServiceInterface; |
| 12 | use App\Core\DataExchange\Application\Service\DataImportServiceInterface; |
| 13 | use App\Core\DataExchange\Domain\Model\ExportConfigDto; |
| 14 | use App\Core\DataExchange\Domain\Model\ImportMappingConfig; |
| 15 | use App\Core\Engine\Domain\Model\PermissionContext; |
| 16 | use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface; |
| 17 | use App\Modules\Automation\Queue\Domain\Model\QueueJob; |
| 18 | use App\Modules\Automation\Queue\Domain\Repository\QueueRepositoryInterface; |
| 19 | use Nyholm\Psr7\Factory\Psr17Factory; |
| 20 | use Nyholm\Psr7\Response; |
| 21 | use Nyholm\Psr7\Stream; |
| 22 | use PDO; |
| 23 | use Psr\Http\Message\ResponseInterface; |
| 24 | use Psr\Http\Message\ServerRequestInterface; |
| 25 | use Psr\Http\Message\UploadedFileInterface; |
| 26 | use Throwable; |
| 27 | use Twig\Environment as TwigEnvironment; |
| 28 | |
| 29 | /** |
| 30 | * HTMX & Web Controller managing Data Import and Export workflows across all engine modules. |
| 31 | * |
| 32 | * @package App\Core\DataExchange\Presentation\Htmx |
| 33 | */ |
| 34 | final readonly class DataExchangeHtmxController |
| 35 | { |
| 36 | private const string HTML_CONTENT_TYPE = 'text/html; charset=UTF-8'; |
| 37 | |
| 38 | /** |
| 39 | * DataExchangeHtmxController constructor. |
| 40 | */ |
| 41 | public function __construct( |
| 42 | private DataExportServiceInterface $exportService, |
| 43 | private DataImportServiceInterface $importService, |
| 44 | private MetadataRepositoryInterface $metadataRepo, |
| 45 | private ?QueueRepositoryInterface $queueRepo, |
| 46 | private TwigEnvironment $twig, |
| 47 | private Psr17Factory $psr17, |
| 48 | private PDO $pdo, |
| 49 | private string $tablePrefix = 'a_' |
| 50 | ) { |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Renders modal dialog for exporting data (CSV/XLSX). |
| 55 | */ |
| 56 | public function actionExportModal( |
| 57 | ServerRequestInterface $request, |
| 58 | string $moduleName |
| 59 | ): ResponseInterface { |
| 60 | $module = $this->metadataRepo->findModule($moduleName); |
| 61 | $fields = $this->metadataRepo->findFields($module->id); |
| 62 | $params = $request->getQueryParams(); |
| 63 | |
| 64 | $html = $this->twig->render('data_exchange/export_modal.twig', [ |
| 65 | 'module' => $module, |
| 66 | 'fields' => $fields, |
| 67 | 'scope' => $params['scope'] ?? 'all', |
| 68 | 'selected_ids' => $params['ids'] ?? '', |
| 69 | ]); |
| 70 | |
| 71 | return $this->htmlResponse($html); |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Executes streaming file generation and initiates HTTP browser download. |
| 76 | */ |
| 77 | public function actionExportDownload( |
| 78 | ServerRequestInterface $request, |
| 79 | string $moduleName, |
| 80 | PermissionContext $context |
| 81 | ): ResponseInterface { |
| 82 | $body = (array) $request->getParsedBody(); |
| 83 | $selectedIdsRaw = (string) ($body['selected_ids'] ?? ''); |
| 84 | $selectedIds = $selectedIdsRaw !== '' ? array_map('intval', explode(',', $selectedIdsRaw)) : []; |
| 85 | |
| 86 | $config = new ExportConfigDto( |
| 87 | moduleName: $moduleName, |
| 88 | format: (string) ($body['format'] ?? 'xlsx'), |
| 89 | scope: (string) ($body['scope'] ?? 'all'), |
| 90 | recordIds: $selectedIds, |
| 91 | selectedColumns: !empty($body['fields']) ? (array) $body['fields'] : [], |
| 92 | csvDelimiter: (string) ($body['delimiter'] ?? ','), |
| 93 | includeBom: !empty($body['include_bom']) |
| 94 | ); |
| 95 | |
| 96 | $result = $this->exportService->export($moduleName, $config, $context); |
| 97 | |
| 98 | $handle = @fopen($result['file_path'], 'rb'); |
| 99 | if ($handle === false) { |
| 100 | return $this->psr17->createResponse(500); |
| 101 | } |
| 102 | |
| 103 | $stream = Stream::create($handle); |
| 104 | $cleanFileName = str_replace(['"', "\r", "\n"], '', $result['file_name']); |
| 105 | |
| 106 | return new Response(200, [ |
| 107 | 'Content-Type' => $result['mime_type'], |
| 108 | 'Content-Disposition' => 'attachment; filename="' . $cleanFileName . '"', |
| 109 | 'Content-Length' => (string) $result['size_bytes'], |
| 110 | 'Cache-Control' => 'no-cache, private', |
| 111 | 'X-Content-Type-Options' => 'nosniff', |
| 112 | ], $stream); |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Renders initial Step 1 of Data Import Wizard. |
| 117 | */ |
| 118 | public function actionImportModal( |
| 119 | string $moduleName |
| 120 | ): ResponseInterface { |
| 121 | $module = $this->metadataRepo->findModule($moduleName); |
| 122 | |
| 123 | $html = $this->twig->render('data_exchange/import_modal.twig', [ |
| 124 | 'module' => $module, |
| 125 | ]); |
| 126 | |
| 127 | return $this->htmlResponse($html); |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Handles file upload and renders Step 2 (Field Mapping). |
| 132 | */ |
| 133 | public function actionUploadAndAnalyze( |
| 134 | ServerRequestInterface $request, |
| 135 | string $moduleName |
| 136 | ): ResponseInterface { |
| 137 | $module = $this->metadataRepo->findModule($moduleName); |
| 138 | $files = $request->getUploadedFiles(); |
| 139 | |
| 140 | /** @var UploadedFileInterface|null $uploadedFile */ |
| 141 | $uploadedFile = $files['import_file'] ?? null; |
| 142 | if ($uploadedFile === null || $uploadedFile->getError() !== UPLOAD_ERR_OK) { |
| 143 | return $this->htmlResponse('<div class="alert alert-danger">No valid file uploaded.</div>'); |
| 144 | } |
| 145 | |
| 146 | $tempDir = sys_get_temp_dir(); |
| 147 | $clientFilename = $uploadedFile->getClientFilename() ?? 'import.csv'; |
| 148 | $ext = pathinfo($clientFilename, PATHINFO_EXTENSION); |
| 149 | $rand = bin2hex(random_bytes(6)); |
| 150 | $tempPath = sprintf('%s%simport_%s_%s.%s', $tempDir, DIRECTORY_SEPARATOR, $moduleName, $rand, $ext); |
| 151 | |
| 152 | $uploadedFile->moveTo($tempPath); |
| 153 | |
| 154 | $analysis = $this->importService->analyzeFile($tempPath, $moduleName); |
| 155 | |
| 156 | $html = $this->twig->render('data_exchange/step_mapping.twig', [ |
| 157 | 'module' => $module, |
| 158 | 'temp_file_path' => $tempPath, |
| 159 | 'file_headers' => $analysis['file_headers'], |
| 160 | 'sample_rows' => $analysis['sample_rows'], |
| 161 | 'module_fields' => $analysis['module_fields'], |
| 162 | 'suggested_mapping' => $analysis['suggested_mapping'], |
| 163 | ]); |
| 164 | |
| 165 | return $this->htmlResponse($html); |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Initiates import process (synchronous or background queue). |
| 170 | */ |
| 171 | public function actionStartImport( |
| 172 | ServerRequestInterface $request, |
| 173 | string $moduleName, |
| 174 | PermissionContext $context |
| 175 | ): ResponseInterface { |
| 176 | $module = $this->metadataRepo->findModule($moduleName); |
| 177 | $body = (array) $request->getParsedBody(); |
| 178 | |
| 179 | $tempPath = (string) ($body['temp_file_path'] ?? ''); |
| 180 | $mapping = (array) ($body['mapping'] ?? []); |
| 181 | $dedupStrategy = (string) ($body['deduplication_strategy'] ?? 'skip_duplicates'); |
| 182 | $uniqueField = !empty($body['unique_identifier_field']) ? (string) $body['unique_identifier_field'] : null; |
| 183 | $isDryRun = !empty($body['is_dry_run']); |
| 184 | |
| 185 | $config = new ImportMappingConfig( |
| 186 | columnMapping: $mapping, |
| 187 | deduplicationStrategy: $dedupStrategy, |
| 188 | uniqueIdentifierField: $uniqueField, |
| 189 | isDryRun: $isDryRun |
| 190 | ); |
| 191 | |
| 192 | if ($this->queueRepo !== null && !$isDryRun) { |
| 193 | $jobId = $this->createImportJobRecord($moduleName, $tempPath, $context); |
| 194 | $this->queueRepo->enqueue( |
| 195 | 'data_import', |
| 196 | sprintf('Import %s: %s', $moduleName, basename($tempPath)), |
| 197 | [ |
| 198 | 'import_job_id' => $jobId, |
| 199 | 'module_name' => $moduleName, |
| 200 | 'file_path' => $tempPath, |
| 201 | 'mapping_config' => $config->toArray(), |
| 202 | 'user_id' => $context->actorUserId, |
| 203 | 'profile_id' => $context->actorProfileId ?? 1, |
| 204 | 'is_super_admin' => $context->isSuperuser, |
| 205 | ], |
| 206 | $context->actorUserId, |
| 207 | $context->actorUserId |
| 208 | ); |
| 209 | |
| 210 | $html = $this->twig->render('data_exchange/step_progress.twig', [ |
| 211 | 'module' => $module, |
| 212 | 'job_id' => $jobId, |
| 213 | 'total_rows' => 0, |
| 214 | 'processed_rows' => 0, |
| 215 | 'status' => 'pending', |
| 216 | ]); |
| 217 | |
| 218 | return $this->htmlResponse($html); |
| 219 | } |
| 220 | |
| 221 | $result = $this->importService->executeImport($moduleName, $tempPath, $config, $context); |
| 222 | |
| 223 | $html = $this->twig->render('data_exchange/step_summary.twig', [ |
| 224 | 'module' => $module, |
| 225 | 'total_rows' => $result->totalRows, |
| 226 | 'imported_rows' => $result->importedRows, |
| 227 | 'updated_rows' => $result->updatedRows, |
| 228 | 'skipped_rows' => $result->skippedRows, |
| 229 | 'failed_rows' => $result->failedRows, |
| 230 | 'errors' => $result->errors, |
| 231 | ]); |
| 232 | |
| 233 | return $this->htmlResponse($html); |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * Polls progress of an active background import job. |
| 238 | */ |
| 239 | public function actionImportProgress( |
| 240 | string $moduleName, |
| 241 | int $jobId |
| 242 | ): ResponseInterface { |
| 243 | $module = $this->metadataRepo->findModule($moduleName); |
| 244 | $sql = "SELECT id, module_name, file_name, file_path, total_rows, processed_rows, imported_rows, " . |
| 245 | "updated_rows, skipped_rows, failed_rows, status, error_summary, created_at, updated_at " . |
| 246 | "FROM {$this->tablePrefix}mod_import_jobs_records WHERE id = :id LIMIT 1"; |
| 247 | $stmt = $this->pdo->prepare($sql); |
| 248 | $stmt->execute([':id' => $jobId]); |
| 249 | $job = $stmt->fetch(PDO::FETCH_ASSOC); |
| 250 | |
| 251 | if ($job === false) { |
| 252 | return $this->htmlResponse('<div class="alert alert-danger">Import job not found.</div>'); |
| 253 | } |
| 254 | |
| 255 | if ($job['status'] === 'completed' || $job['status'] === 'failed') { |
| 256 | $errors = !empty($job['error_summary']) ? (array) json_decode((string) $job['error_summary'], true) : []; |
| 257 | $html = $this->twig->render('data_exchange/step_summary.twig', [ |
| 258 | 'module' => $module, |
| 259 | 'total_rows' => (int) $job['total_rows'], |
| 260 | 'imported_rows' => (int) $job['imported_rows'], |
| 261 | 'updated_rows' => (int) $job['updated_rows'], |
| 262 | 'skipped_rows' => (int) $job['skipped_rows'], |
| 263 | 'failed_rows' => (int) $job['failed_rows'], |
| 264 | 'errors' => $errors, |
| 265 | ]); |
| 266 | return $this->htmlResponse($html); |
| 267 | } |
| 268 | |
| 269 | $html = $this->twig->render('data_exchange/step_progress.twig', [ |
| 270 | 'module' => $module, |
| 271 | 'job_id' => $jobId, |
| 272 | 'total_rows' => (int) $job['total_rows'], |
| 273 | 'processed_rows' => (int) $job['processed_rows'], |
| 274 | 'status' => (string) $job['status'], |
| 275 | ]); |
| 276 | |
| 277 | return $this->htmlResponse($html); |
| 278 | } |
| 279 | |
| 280 | /** |
| 281 | * Creates tracking record in database for background import job. |
| 282 | */ |
| 283 | private function createImportJobRecord(string $moduleName, string $filePath, PermissionContext $context): int |
| 284 | { |
| 285 | $sql = "INSERT INTO {$this->tablePrefix}mod_import_jobs_records " . |
| 286 | '(module_name, file_name, file_path, total_rows, processed_rows, imported_rows, ' . |
| 287 | 'updated_rows, skipped_rows, failed_rows, status, created_by, created_at, updated_at) ' . |
| 288 | "VALUES (:module, :fname, :fpath, 0, 0, 0, 0, 0, 0, 'pending', :uid, :created_at, :updated_at)"; |
| 289 | |
| 290 | $now = date('Y-m-d H:i:s'); |
| 291 | $stmt = $this->pdo->prepare($sql); |
| 292 | $stmt->execute([ |
| 293 | ':module' => $moduleName, |
| 294 | ':fname' => basename($filePath), |
| 295 | ':fpath' => $filePath, |
| 296 | ':uid' => $context->actorUserId, |
| 297 | ':created_at' => $now, |
| 298 | ':updated_at' => $now, |
| 299 | ]); |
| 300 | |
| 301 | return (int) $this->pdo->lastInsertId(); |
| 302 | } |
| 303 | |
| 304 | /** |
| 305 | * Helper creating standardized HTML response. |
| 306 | */ |
| 307 | private function htmlResponse(string $html, int $status = 200): ResponseInterface |
| 308 | { |
| 309 | $response = $this->psr17->createResponse($status) |
| 310 | ->withHeader('Content-Type', self::HTML_CONTENT_TYPE); |
| 311 | $response->getBody()->write($html); |
| 312 | |
| 313 | return $response; |
| 314 | } |
| 315 | } |