Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
160 / 160
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
DataExchangeHtmxController
100.00% covered (success)
100.00%
159 / 159
100.00% covered (success)
100.00%
9 / 9
21
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionExportModal
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 actionExportDownload
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 actionImportModal
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 actionUploadAndAnalyze
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 actionStartImport
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
4
 actionImportProgress
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
5
 createImportJobRecord
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 htmlResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\DataExchange\Presentation\Htmx;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\DataExchange\Application\Service\DataExportServiceInterface;
12use App\Core\DataExchange\Application\Service\DataImportServiceInterface;
13use App\Core\DataExchange\Domain\Model\ExportConfigDto;
14use App\Core\DataExchange\Domain\Model\ImportMappingConfig;
15use App\Core\Engine\Domain\Model\PermissionContext;
16use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
17use App\Modules\Automation\Queue\Domain\Model\QueueJob;
18use App\Modules\Automation\Queue\Domain\Repository\QueueRepositoryInterface;
19use Nyholm\Psr7\Factory\Psr17Factory;
20use Nyholm\Psr7\Response;
21use Nyholm\Psr7\Stream;
22use PDO;
23use Psr\Http\Message\ResponseInterface;
24use Psr\Http\Message\ServerRequestInterface;
25use Psr\Http\Message\UploadedFileInterface;
26use Throwable;
27use 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 */
34final 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}