Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
128 / 128
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
PdfQueueHtmxController
100.00% covered (success)
100.00%
127 / 127
100.00% covered (success)
100.00%
6 / 6
19
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
 createQueue
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
 status
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
4
 download
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 insertQueueRecord
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 fetchJob
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
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\Modules\Pdf\Presentation\Htmx;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Database\FallbackPdoResolver;
12use App\Modules\Pdf\Task\ProcessPdfQueueTask;
13use PDO;
14use Psr\Http\Message\ResponseFactoryInterface;
15use Psr\Http\Message\ResponseInterface;
16use Psr\Http\Message\ServerRequestInterface;
17use Twig\Environment as TwigEnvironment;
18
19/**
20 * HTMX Controller for Asynchronous Batch PDF Generation Queue & Polling.
21 *
22 * @package App\Modules\Pdf\Presentation\Htmx
23 */
24final readonly class PdfQueueHtmxController
25{
26    private const string CONTENT_TYPE_HTML = 'text/html; charset=UTF-8';
27
28    private ?PDO $pdo;
29
30    /**
31     * PdfQueueHtmxController constructor.
32     *
33     * @param TwigEnvironment          $twig            Twig template engine.
34     * @param ResponseFactoryInterface $responseFactory PSR-17 response factory.
35     * @param PDO|null                 $pdo             Database connection handle.
36     */
37    public function __construct(
38        private TwigEnvironment $twig,
39        private ResponseFactoryInterface $responseFactory,
40        ?PDO $pdo = null
41    ) {
42        $this->pdo = $pdo ?? FallbackPdoResolver::resolveDefaultConnection();
43    }
44
45    /**
46     * Enqueues batch PDF generation task and returns modal with live polling.
47     *
48     * @param ServerRequestInterface $request HTTP request.
49     * @return ResponseInterface Modal HTML snippet with progress bar.
50     */
51    public function createQueue(ServerRequestInterface $request): ResponseInterface
52    {
53        $parsedBody = (array) ($request->getParsedBody() ?? []);
54        $templateId = (int) ($parsedBody['template_id'] ?? 0);
55        $module = (string) ($parsedBody['module_name'] ?? '');
56        $rawRecords = $parsedBody['record_ids'] ?? [];
57
58        $recordIds = [];
59        if (is_array($rawRecords)) {
60            $mapped = array_map('intval', $rawRecords);
61            $recordIds = array_values(array_filter($mapped, static fn(int $id): bool => $id > 0));
62        } elseif (is_string($rawRecords) && trim($rawRecords) !== '') {
63            $mapped = array_map('intval', explode(',', $rawRecords));
64            $recordIds = array_values(array_filter($mapped, static fn(int $id): bool => $id > 0));
65        }
66
67        $totalCount = count($recordIds);
68        $queueId = $this->insertQueueRecord($templateId, $module, $recordIds, $totalCount);
69
70        // Optionally kick off an immediate worker run
71        $worker = new ProcessPdfQueueTask(null, $this->pdo);
72        $worker->run();
73
74        $html = $this->twig->render('modules/pdf_templates/partials/modal_queue_progress.twig', [
75            'queue_id'         => $queueId,
76            'total_count'      => $totalCount,
77            'processed_count'  => 0,
78            'progress_percent' => 0,
79            'status'           => 'pending',
80            'module_name'      => $module,
81        ]);
82
83        $response = $this->responseFactory->createResponse(200)
84            ->withHeader('Content-Type', self::CONTENT_TYPE_HTML);
85        $response->getBody()->write($html);
86
87        return $response;
88    }
89
90    /**
91     * Returns real-time status snippet for HTMX polling.
92     *
93     * @param int $queueId Batch queue job ID.
94     * @return ResponseInterface Progress bar or download button snippet.
95     */
96    public function status(int $queueId): ResponseInterface
97    {
98        $job = $this->fetchJob($queueId);
99        if ($job === null) {
100            $response = $this->responseFactory->createResponse(404)
101                ->withHeader('Content-Type', self::CONTENT_TYPE_HTML);
102            $response->getBody()->write('<div class="alert alert-danger">Task not found.</div>');
103            return $response;
104        }
105
106        $status = (string)($job['status'] ?? 'pending');
107        $percent = (int)($job['progress_percent'] ?? 0);
108        $total = (int)($job['total_count'] ?? 0);
109        $processed = (int)($job['processed_count'] ?? 0);
110
111        if ($status === 'completed') {
112            $html = sprintf(
113                '<div class="text-center py-3">'
114                . '<div class="text-success display-4 mb-2">&#10004;</div>'
115                . '<h4 class="text-success mb-1">Generation completed successfully!</h4>'
116                . '<p class="text-secondary mb-3">Successfully generated %d of %d documents.</p>'
117                . '<a href="/pdf/queue/download/%d" class="btn btn-success btn-lg shadow-sm">'
118                . 'Download ZIP archive</a></div>',
119                $processed,
120                $total,
121                $queueId
122            );
123        } elseif ($status === 'failed') {
124            $err = htmlspecialchars((string)($job['error_message'] ?? 'Generation error'), ENT_QUOTES, 'UTF-8');
125            $html = sprintf(
126                '<div class="alert alert-danger py-3 text-center">'
127                . '<h4 class="alert-title">An error occurred during bulk generation</h4>'
128                . '<p class="text-secondary mb-0">%s</p></div>',
129                $err
130            );
131        } else {
132            // Trigger worker run and keep polling
133            $worker = new ProcessPdfQueueTask(null, $this->pdo);
134            $worker->run();
135
136            $html = sprintf(
137                '<div class="pdf-queue-poller" hx-get="/htmx/pdf/queue/status/%d" '
138                . 'hx-trigger="load delay:1s" hx-swap="outerHTML">'
139                . '<div class="d-flex justify-content-between mb-2">'
140                . '<span class="fw-bold">Generating PDF documents...</span>'
141                . '<span class="text-secondary">%d%% (%d / %d)</span></div>'
142                . '<div class="progress progress-lg">'
143                . '<div class="progress-bar progress-bar-indeterminate bg-primary" '
144                . 'style="width: %d%%" role="progressbar"></div></div>'
145                . '<small class="text-secondary d-block mt-2 text-center">'
146                . 'Task processing in the background. This window will update automatically.</small></div>',
147                $queueId,
148                $percent,
149                $processed,
150                $total,
151                max(5, $percent)
152            );
153        }
154
155        $response = $this->responseFactory->createResponse(200)
156            ->withHeader('Content-Type', self::CONTENT_TYPE_HTML);
157        $response->getBody()->write($html);
158
159        return $response;
160    }
161
162    /**
163     * Streams resulting ZIP archive for download: GET /pdf/queue/download/{queueId}
164     *
165     * @param int $queueId Batch queue job ID.
166     * @return ResponseInterface File download response.
167     */
168    public function download(int $queueId): ResponseInterface
169    {
170        $job = $this->fetchJob($queueId);
171        $zipPath = (string)($job['result_zip_path'] ?? '');
172
173        if ($job === null || $zipPath === '' || !file_exists($zipPath)) {
174            $response = $this->responseFactory->createResponse(404)
175                ->withHeader('Content-Type', self::CONTENT_TYPE_HTML);
176            $response->getBody()->write('ZIP archive file does not exist.');
177            return $response;
178        }
179
180        $zipData = file_get_contents($zipPath) ?: '';
181        $filename = 'ammonly_batch_' . $queueId . '.zip';
182
183        $response = $this->responseFactory->createResponse(200)
184            ->withHeader('Content-Type', 'application/zip')
185            ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"')
186            ->withHeader('Content-Length', (string)strlen($zipData))
187            ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
188
189        $response->getBody()->write($zipData);
190
191        return $response;
192    }
193
194    /**
195     * Inserts new batch queue entry into database.
196     *
197     * @param int        $templateId Template ID.
198     * @param string     $module     Module name.
199     * @param array<int> $recordIds  Record IDs.
200     * @param int        $totalCount Total records.
201     * @return int Inserted queue ID.
202     */
203    private function insertQueueRecord(int $templateId, string $module, array $recordIds, int $totalCount): int
204    {
205        if ($this->pdo === null) {
206            return 1;
207        }
208
209        $stmt = $this->pdo->prepare(
210            'INSERT INTO `a_mod_pdf_queue_records` ('
211            . '`template_id`, `module_name`, `record_ids_json`, `status`, `total_count`, `scheduled_at`'
212            . ') VALUES (:tid, :mod, :json, :status, :tot, NOW(6))'
213        );
214        $stmt->execute([
215            ':tid'    => $templateId,
216            ':mod'    => $module,
217            ':json'   => json_encode($recordIds, JSON_THROW_ON_ERROR),
218            ':status' => 'pending',
219            ':tot'    => $totalCount,
220        ]);
221
222        return (int)$this->pdo->lastInsertId();
223    }
224
225    /**
226     * Fetches job record by ID.
227     *
228     * @param int $queueId Queue ID.
229     * @return array<string, mixed>|null Job record dictionary or null.
230     */
231    private function fetchJob(int $queueId): ?array
232    {
233        if ($this->pdo === null) {
234            return [
235                'status'           => 'completed',
236                'progress_percent' => 100,
237                'total_count'      => 5,
238                'processed_count'  => 5,
239                'result_zip_path'  => '',
240            ];
241        }
242
243        $stmt = $this->pdo->prepare(
244            'SELECT `id`, `status`, `progress_percent`, `total_count`, `processed_count`, `result_zip_path`, '
245            . '`error_message` FROM `a_mod_pdf_queue_records` WHERE `id` = :id LIMIT 1'
246        );
247        $stmt->execute([':id' => $queueId]);
248        $row = $stmt->fetch(PDO::FETCH_ASSOC);
249
250        return is_array($row) ? $row : null;
251    }
252}