Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.33% covered (warning)
83.33%
90 / 108
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfWebController
83.18% covered (warning)
83.18%
89 / 107
63.64% covered (warning)
63.64%
7 / 11
44.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 download
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 exportRecord
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 bulkDownload
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 plainTextResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 checkRecordPermission
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 generateDownloadResponse
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 checkBulkRecordPermissions
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
22.94
 generateBulkResponse
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 builder
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 saveBlocks
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
10.03
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\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Pdf\Application\Service\PdfGeneratorServiceInterface;
12use App\Modules\Pdf\Domain\Repository\PdfTemplateRepositoryInterface;
13use Psr\Http\Message\ResponseFactoryInterface;
14use Psr\Http\Message\ResponseInterface;
15use Psr\Http\Message\ServerRequestInterface;
16use Throwable;
17use Twig\Environment as TwigEnvironment;
18
19use App\Core\Database\FallbackPdoResolver;
20use App\Core\Engine\Application\Security\PermissionContextFactory;
21use App\Core\Engine\Application\Service\UniversalCrudService;
22use App\Core\Template\Application\Service\TemplateCompilerService;
23use PDO;
24
25/**
26 * Web Controller for Streaming and Downloading Generated PDF Documents.
27 *
28 * @package App\Modules\Pdf\Presentation\Web
29 */
30final readonly class PdfWebController
31{
32    private const string HEADER_CACHE_NO_STORE = 'no-store, no-cache, must-revalidate, max-age=0';
33    private const string CONTENT_TYPE_PLAIN = 'text/plain; charset=utf-8';
34    private const string MSG_AUTH_REQUIRED = 'Uwierzytelnienie jest wymagane.';
35
36    private ?PDO $pdo;
37
38    /**
39     * PdfWebController constructor.
40     *
41     * @param ResponseFactoryInterface            $responseFactory PSR-17 response factory.
42     * @param PdfGeneratorServiceInterface        $generator       PDF generator service.
43     * @param TwigEnvironment|null                $twig            Twig template engine.
44     * @param PdfTemplateRepositoryInterface|null $repository      Template repository.
45     * @param PDO|null                            $pdo             Optional database handle.
46     * @param UniversalCrudService|null           $crudService     Optional CRUD service for permission checks.
47     * @param PermissionContextFactory|null       $contextFactory  Optional permission context factory.
48     */
49    public function __construct(
50        private ResponseFactoryInterface $responseFactory,
51        private PdfGeneratorServiceInterface $generator,
52        private ?TwigEnvironment $twig = null,
53        private ?PdfTemplateRepositoryInterface $repository = null,
54        ?PDO $pdo = null,
55        private ?UniversalCrudService $crudService = null,
56        private ?PermissionContextFactory $contextFactory = null
57    ) {
58        $this->pdo = $pdo ?? FallbackPdoResolver::resolveDefaultConnection();
59    }
60
61    /**
62     * Handles GET /pdf/download/{templateId}/{recordId}?module={moduleName}
63     *
64     * @param ServerRequestInterface $request    Incoming request.
65     * @param int                    $templateId Target template ID.
66     * @param int                    $recordId   Primary record identifier.
67     * @return ResponseInterface Binary PDF file download stream.
68     */
69    public function download(
70        ServerRequestInterface $request,
71        int $templateId,
72        int $recordId
73    ): ResponseInterface {
74        $queryParams = $request->getQueryParams();
75        $module = (string)($queryParams['module'] ?? 'tickets');
76
77        $permError = $this->checkRecordPermission($request, $module, $recordId);
78        if ($permError !== null) {
79            return $permError;
80        }
81
82        return $this->generateDownloadResponse($templateId, $module, $recordId);
83    }
84
85    /**
86     * Handles GET /pdf/export/{moduleName}/{recordId}
87     *
88     * Finds active template for module and generates direct PDF download.
89     *
90     * @param ServerRequestInterface $request    Incoming request.
91     * @param string                 $moduleName Target module name.
92     * @param int                    $recordId   Target record ID.
93     * @return ResponseInterface Binary PDF file download stream.
94     */
95    public function exportRecord(
96        ServerRequestInterface $request,
97        string $moduleName,
98        int $recordId
99    ): ResponseInterface {
100        $permError = $this->checkRecordPermission($request, $moduleName, $recordId);
101        if ($permError !== null) {
102            return $permError;
103        }
104
105        $templates = $this->repository?->findAllActiveByModule($moduleName) ?? [];
106        if ($templates === []) {
107            return $this->plainTextResponse("No active PDF template found for module '{$moduleName}'.", 404);
108        }
109
110        $template = $templates[0];
111        return $this->generateDownloadResponse($template->id, $moduleName, $recordId);
112    }
113
114    /**
115     * Handles POST /pdf/bulk-download
116     *
117     * @param ServerRequestInterface $request Incoming form POST request.
118     * @return ResponseInterface Binary consolidated PDF file download stream.
119     */
120    public function bulkDownload(ServerRequestInterface $request): ResponseInterface
121    {
122        $body = (array)$request->getParsedBody();
123        $templateId = (int)($body['template_id'] ?? 0);
124        $module = (string)($body['module_name'] ?? 'tickets');
125        $rawIds = (string)($body['record_ids'] ?? '');
126        $recordIds = array_values(array_filter(array_map('intval', explode(',', $rawIds))));
127
128        if ($templateId <= 0 || $recordIds === []) {
129            return $this->plainTextResponse('Invalid bulk download parameters.', 400);
130        }
131
132        $permError = $this->checkBulkRecordPermissions($request, $module, $recordIds);
133        if ($permError !== null) {
134            return $permError;
135        }
136
137        return $this->generateBulkResponse($templateId, $module, $recordIds);
138    }
139    /**
140     * Builds plain text error or info response.
141     *
142     * @param string $message Response text message.
143     * @param int    $status  HTTP status code.
144     * @return ResponseInterface Formatted plain text response.
145     */
146    private function plainTextResponse(string $message, int $status): ResponseInterface
147    {
148        $response = $this->responseFactory->createResponse($status)
149            ->withHeader('Content-Type', self::CONTENT_TYPE_PLAIN);
150        $response->getBody()->write($message);
151
152        return $response;
153    }
154
155    /**
156     * Verifies read permission for a single record.
157     *
158     * @param ServerRequestInterface $request  Incoming request.
159     * @param string                 $module   Module name.
160     * @param int                    $recordId Record ID.
161     * @return ResponseInterface|null Null if allowed, or error response.
162     */
163    private function checkRecordPermission(
164        ServerRequestInterface $request,
165        string $module,
166        int $recordId
167    ): ?ResponseInterface {
168        if ($this->crudService !== null && $this->contextFactory !== null) {
169            $context = $this->contextFactory->createFromRequest($request);
170            if (!$context->isAuthenticated()) {
171                return $this->plainTextResponse(self::MSG_AUTH_REQUIRED, 401);
172            }
173
174            try {
175                $this->crudService->read($module, $recordId, $context);
176            } catch (Throwable) {
177                return $this->plainTextResponse('Permission denied for the requested record.', 403);
178            }
179        }
180
181        return null;
182    }
183
184    /**
185     * Generates PDF binary download response.
186     *
187     * @param int    $templateId Template ID.
188     * @param string $module     Module name.
189     * @param int    $recordId   Record ID.
190     * @return ResponseInterface Binary PDF response.
191     */
192    private function generateDownloadResponse(int $templateId, string $module, int $recordId): ResponseInterface
193    {
194        try {
195            $pdf = $this->generator->generateForRecord($templateId, $module, $recordId);
196
197            $response = $this->responseFactory->createResponse(200)
198                ->withHeader('Content-Type', 'application/pdf')
199                ->withHeader('Content-Disposition', 'attachment; filename="' . $pdf['filename'] . '"')
200                ->withHeader('Cache-Control', self::HEADER_CACHE_NO_STORE);
201            $response->getBody()->write($pdf['content']);
202
203            return $response;
204        } catch (Throwable $e) {
205            return $this->plainTextResponse('Error generating PDF document: ' . $e->getMessage(), 500);
206        }
207    }
208
209    /**
210     * Verifies read permission for multiple records.
211     *
212     * @param ServerRequestInterface $request   Incoming request.
213     * @param string                 $module    Module name.
214     * @param list<int>              $recordIds Record IDs.
215     * @return ResponseInterface|null Null if allowed, or error response.
216     */
217    private function checkBulkRecordPermissions(
218        ServerRequestInterface $request,
219        string $module,
220        array $recordIds
221    ): ?ResponseInterface {
222        if ($this->crudService !== null && $this->contextFactory !== null) {
223            $context = $this->contextFactory->createFromRequest($request);
224            if (!$context->isAuthenticated()) {
225                return $this->plainTextResponse(self::MSG_AUTH_REQUIRED, 401);
226            }
227
228            try {
229                foreach ($recordIds as $rid) {
230                    $this->crudService->read($module, $rid, $context);
231                }
232            } catch (Throwable) {
233                return $this->plainTextResponse('Permission denied for one or more records.', 403);
234            }
235        }
236
237        return null;
238    }
239
240    /**
241     * Generates bulk PDF binary download response.
242     *
243     * @param int       $templateId Template ID.
244     * @param string    $module     Module name.
245     * @param list<int> $recordIds  Record IDs.
246     * @return ResponseInterface Binary consolidated PDF response.
247     */
248    private function generateBulkResponse(int $templateId, string $module, array $recordIds): ResponseInterface
249    {
250        try {
251            $pdf = $this->generator->generateBulk($templateId, $module, $recordIds);
252
253            $response = $this->responseFactory->createResponse(200)
254                ->withHeader('Content-Type', 'application/pdf')
255                ->withHeader('Content-Disposition', 'attachment; filename="' . $pdf['filename'] . '"')
256                ->withHeader('Cache-Control', self::HEADER_CACHE_NO_STORE);
257            $response->getBody()->write($pdf['content']);
258
259            return $response;
260        } catch (Throwable $e) {
261            return $this->plainTextResponse('Error generating bulk PDF: ' . $e->getMessage(), 500);
262        }
263    }
264
265    /**
266     * Handles GET /pdf/builder/{templateId}
267     *
268     * @param int $templateId Target template ID.
269     * @return ResponseInterface Rendered visual block builder view.
270     */
271    public function builder(int $templateId): ResponseInterface
272    {
273        if ($this->twig === null) {
274            return $this->plainTextResponse('Twig template engine is not configured.', 500);
275        }
276
277        $template = $this->repository?->findById($templateId);
278        $html = $this->twig->render('modules/pdf_templates/builder.twig', [
279            'template'   => $template,
280            'is_email'   => false,
281            'save_url'   => '/pdf/templates/save-blocks/' . $templateId,
282            'return_url' => '/pdf_templates/' . $templateId,
283        ]);
284
285        $response = $this->responseFactory->createResponse(200)
286            ->withHeader('Content-Type', 'text/html; charset=utf-8');
287        $response->getBody()->write($html);
288
289        return $response;
290    }
291
292    /**
293     * Handles POST /pdf/templates/save-blocks/{templateId}
294     *
295     * @param ServerRequestInterface $request    Incoming request.
296     * @param int                    $templateId Target template ID.
297     * @return ResponseInterface Redirect to template detail view.
298     */
299    public function saveBlocks(ServerRequestInterface $request, int $templateId): ResponseInterface
300    {
301        if ($this->crudService !== null && $this->contextFactory !== null) {
302            $context = $this->contextFactory->createFromRequest($request);
303            if (!$context->isAuthenticated()) {
304                return $this->plainTextResponse(self::MSG_AUTH_REQUIRED, 401);
305            }
306
307            try {
308                $modMeta = $this->crudService->getMetadataRepository()->findModule('pdf_templates');
309                $this->crudService->getGuard()->assertWriteAccess($modMeta, $context);
310            } catch (Throwable) {
311                return $this->plainTextResponse('Permission denied to edit PDF templates.', 403);
312            }
313        }
314
315        $body = $request->getParsedBody();
316        $data = is_array($body) ? $body : [];
317
318        $rawBlocks = (string)($data['blocks_json'] ?? '');
319        $cssStyles = (string)($data['css_styles'] ?? '');
320
321        $compiler = new TemplateCompilerService();
322        $compiledHtml = $rawBlocks !== '' ? $compiler->compileBlocks($rawBlocks) : (string)($data['body_html'] ?? '');
323
324        if ($this->pdo !== null) {
325            $stmt = $this->pdo->prepare(
326                'UPDATE `a_mod_pdf_template_records` SET `blocks_json` = :blocks, `body_html` = :html, '
327                . '`css_styles` = :css, `updated_at` = CURRENT_TIMESTAMP WHERE `id` = :id'
328            );
329            $stmt->execute([
330                ':blocks' => $rawBlocks !== '' ? $rawBlocks : null,
331                ':html'   => $compiledHtml,
332                ':css'    => $cssStyles !== '' ? $cssStyles : null,
333                ':id'     => $templateId,
334            ]);
335        }
336
337        $returnUrl = (string)($data['return_url'] ?? ('/pdf_templates/' . $templateId));
338
339        return $this->responseFactory->createResponse(302)
340            ->withHeader('Location', $returnUrl)
341            ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
342    }
343}