Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.52% covered (success)
97.52%
118 / 121
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
MailTemplateWebController
97.50% covered (success)
97.50%
117 / 120
50.00% covered (danger)
50.00%
3 / 6
26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 builder
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
8
 saveBlocks
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 resolveDefaultEmailBlocks
97.50% covered (success)
97.50%
39 / 40
0.00% covered (danger)
0.00%
0 / 1
3
 findTemplateRow
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 updateTemplateRow
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
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\Mail\Presentation\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Database\FallbackPdoResolver;
12use App\Modules\Mail\Application\Service\EmailTemplateCompilerService;
13use App\Modules\Mail\Application\Service\EmailTemplateCompilerServiceInterface;
14use App\Modules\Mail\Domain\Service\EmailBlockRegistry;
15use PDO;
16use Psr\Http\Message\ResponseFactoryInterface;
17use Psr\Http\Message\ResponseInterface;
18use Psr\Http\Message\ServerRequestInterface;
19use Throwable;
20use Twig\Environment as TwigEnvironment;
21
22/**
23 * Web Controller for Email Template Visual Block Builder and Block Persistence.
24 *
25 * Exclusively manages email templates with dedicated email-safe block components (email_*),
26 * completely decoupled from PDF printable media.
27 *
28 * @package App\Modules\Mail\Presentation\Web
29 */
30final readonly class MailTemplateWebController
31{
32    private const string CONTENT_TYPE_HTML = 'text/html; charset=utf-8';
33    private const string HEADER_NO_CACHE = 'no-store, no-cache, must-revalidate, max-age=0';
34
35    private ?PDO $pdo;
36    private EmailTemplateCompilerServiceInterface $compiler;
37
38    /**
39     * MailTemplateWebController constructor.
40     *
41     * @param ResponseFactoryInterface                   $responseFactory PSR-17 response factory.
42     * @param TwigEnvironment|null                       $twig            Twig template engine.
43     * @param EmailTemplateCompilerServiceInterface|null $compiler        Email block compiler.
44     * @param PDO|null                                   $pdo             Database connection handle.
45     */
46    public function __construct(
47        private ResponseFactoryInterface $responseFactory,
48        private ?TwigEnvironment $twig = null,
49        ?EmailTemplateCompilerServiceInterface $compiler = null,
50        ?PDO $pdo = null
51    ) {
52        $this->compiler = $compiler ?? new EmailTemplateCompilerService();
53        $this->pdo = $pdo ?? FallbackPdoResolver::resolveDefaultConnection();
54    }
55
56    /**
57     * Handles GET /mail/templates/builder/{templateId}
58     *
59     * @param int $templateId Target template ID.
60     * @return ResponseInterface Rendered visual email block builder view.
61     */
62    public function builder(int $templateId): ResponseInterface
63    {
64        if ($this->twig === null) {
65            $response = $this->responseFactory->createResponse(500)
66                ->withHeader('Content-Type', 'text/plain; charset=utf-8');
67            $response->getBody()->write('Twig template engine is not configured.');
68
69            return $response;
70        }
71
72        $template = $this->findTemplateRow($templateId);
73        if ($template === null) {
74            $response = $this->responseFactory->createResponse(404)
75                ->withHeader('Content-Type', self::CONTENT_TYPE_HTML);
76            $response->getBody()->write('Szablon email o podanym identyfikatorze nie istnieje.');
77
78            return $response;
79        }
80
81        $blocksJson = null;
82        if (!empty($template['blocks_json'])) {
83            $blocksJson = json_decode((string)$template['blocks_json'], true);
84        }
85
86        if (is_array($blocksJson) && array_is_list($blocksJson)) {
87            $blocksJson = ['blocks' => $blocksJson];
88        }
89
90        if (!is_array($blocksJson) || empty($blocksJson['blocks'])) {
91            $blocksJson = $this->resolveDefaultEmailBlocks($template);
92        }
93
94        $templateDto = (object)[
95            'id'           => (int)$template['id'],
96            'name'         => (string)($template['name'] ?? 'Szablon Email'),
97            'code'         => (string)($template['code'] ?? ''),
98            'subject'      => (string)($template['subject'] ?? ''),
99            'bodyHtml'     => (string)($template['body_html'] ?? ''),
100            'blocksJson'   => $blocksJson,
101            'cssStyles'    => (string)($template['css_styles'] ?? ''),
102            'languageCode' => (string)($template['language_code'] ?? 'pl'),
103        ];
104
105        $html = $this->twig->render('modules/mail_templates/builder.twig', [
106            'template'          => $templateDto,
107            'blocks'            => $blocksJson['blocks'] ?? [],
108            'block_definitions' => EmailBlockRegistry::getAll(),
109            'save_url'          => '/mail/templates/save-blocks/' . $templateId,
110            'return_url'        => '/mail/templates/' . $templateId,
111        ]);
112
113        $response = $this->responseFactory->createResponse(200)
114            ->withHeader('Content-Type', self::CONTENT_TYPE_HTML);
115        $response->getBody()->write($html);
116
117        return $response;
118    }
119
120    /**
121     * Handles POST /mail/templates/save-blocks/{templateId}
122     *
123     * @param ServerRequestInterface $request    Incoming request.
124     * @param int                    $templateId Target template ID.
125     * @return ResponseInterface Redirect or confirmation response.
126     */
127    public function saveBlocks(ServerRequestInterface $request, int $templateId): ResponseInterface
128    {
129        $body = $request->getParsedBody();
130        $data = is_array($body) ? $body : [];
131
132        $rawBlocks = (string)($data['blocks_json'] ?? '');
133        $cssStyles = (string)($data['css_styles'] ?? '');
134
135        $compiledHtml = '';
136        if ($rawBlocks !== '') {
137            $compiledHtml = $this->compiler->compileBlocks($rawBlocks, $cssStyles);
138        }
139
140        if ($compiledHtml === '' && isset($data['body_html'])) {
141            $compiledHtml = (string)$data['body_html'];
142        }
143
144        $this->updateTemplateRow($templateId, $rawBlocks, $compiledHtml, $cssStyles);
145
146        $returnUrl = (string)($data['return_url'] ?? ('/mail/templates/' . $templateId));
147
148        return $this->responseFactory->createResponse(302)
149            ->withHeader('Location', $returnUrl)
150            ->withHeader('Cache-Control', self::HEADER_NO_CACHE);
151    }
152
153    /**
154     * Resolves default email blocks when blocks_json is empty.
155     *
156     * @param array<string, mixed> $template Row data.
157     * @return array{blocks: array<int, array<string, mixed>>}
158     */
159    private function resolveDefaultEmailBlocks(array $template): array
160    {
161        $code = (string)($template['code'] ?? '');
162        $lang = (string)($template['language_code'] ?? 'pl');
163        if ($code === 'PASSWORD_RESET') {
164            return ['blocks' => EmailBlockRegistry::getPasswordResetDefaultBlocks($lang)];
165        }
166
167        $name = (string)($template['name'] ?? 'Powiadomienie');
168        $bodyHtml = (string)($template['body_html'] ?? '');
169        $content = $bodyHtml !== ''
170            ? strip_tags($bodyHtml, '<p><br><strong><a><ul><li>')
171            : 'Treść powiadomienia systemowego.';
172
173        return [
174            'blocks' => [
175                [
176                    'id'    => 'eml_def_header',
177                    'type'  => 'email_header_logo',
178                    'sort'  => 10,
179                    'props' => ['app_name' => 'Ammonly', 'align' => 'center'],
180                ],
181                [
182                    'id'    => 'eml_def_heading',
183                    'type'  => 'email_heading',
184                    'sort'  => 20,
185                    'props' => ['text' => $name, 'level' => 'h2', 'align' => 'left', 'color' => '#1e293b'],
186                ],
187                [
188                    'id'    => 'eml_def_paragraph',
189                    'type'  => 'email_paragraph',
190                    'sort'  => 30,
191                    'props' => ['content' => $content, 'align' => 'left', 'color' => '#334155'],
192                ],
193                [
194                    'id'    => 'eml_def_footer',
195                    'type'  => 'email_footer_unsubscribe',
196                    'sort'  => 40,
197                    'props' => [
198                        'company_name' => 'Ammonly Platform',
199                        'notice_text'  => 'Wiadomość wygenerowana automatycznie przez system.',
200                    ],
201                ],
202            ],
203        ];
204    }
205
206    /**
207     * Finds email template row from database.
208     *
209     * @param int $templateId Primary key ID.
210     * @return array<string, mixed>|null Associative row or null.
211     */
212    private function findTemplateRow(int $templateId): ?array
213    {
214        if ($this->pdo === null) {
215            return null;
216        }
217
218        try {
219            $stmt = $this->pdo->prepare(
220                'SELECT `id`, `name`, `code`, `subject`, `body_html`, `body_text`, `blocks_json`, `css_styles`, '
221                . '`language_code` FROM `a_mod_mail_template_records` WHERE `id` = :id LIMIT 1'
222            );
223            $stmt->execute([':id' => $templateId]);
224            $row = $stmt->fetch(PDO::FETCH_ASSOC);
225            if (is_array($row)) {
226                return $row;
227            }
228        } catch (Throwable) {
229            // Log or fallback
230        }
231
232        return null;
233    }
234
235    /**
236     * Updates email template blocks and compiled HTML.
237     */
238    private function updateTemplateRow(
239        int $templateId,
240        string $blocksJson,
241        string $compiledHtml,
242        string $cssStyles
243    ): void {
244        if ($this->pdo === null) {
245            return;
246        }
247
248        try {
249            $stmt = $this->pdo->prepare(
250                'UPDATE `a_mod_mail_template_records` SET `blocks_json` = :blocks, `body_html` = :html, '
251                . '`css_styles` = :css, `updated_at` = CURRENT_TIMESTAMP WHERE `id` = :id'
252            );
253            $stmt->execute([
254                ':blocks' => $blocksJson !== '' ? $blocksJson : null,
255                ':html'   => $compiledHtml,
256                ':css'    => $cssStyles !== '' ? $cssStyles : null,
257                ':id'     => $templateId,
258            ]);
259        } catch (Throwable) {
260            // Fail silently
261        }
262    }
263}