Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.52% |
118 / 121 |
|
50.00% |
3 / 6 |
CRAP | |
0.00% |
0 / 1 |
| MailTemplateWebController | |
97.50% |
117 / 120 |
|
50.00% |
3 / 6 |
26 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| builder | |
100.00% |
39 / 39 |
|
100.00% |
1 / 1 |
8 | |||
| saveBlocks | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
5 | |||
| resolveDefaultEmailBlocks | |
97.50% |
39 / 40 |
|
0.00% |
0 / 1 |
3 | |||
| findTemplateRow | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
4.01 | |||
| updateTemplateRow | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
5.01 | |||
| 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\Modules\Mail\Presentation\Web; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Database\FallbackPdoResolver; |
| 12 | use App\Modules\Mail\Application\Service\EmailTemplateCompilerService; |
| 13 | use App\Modules\Mail\Application\Service\EmailTemplateCompilerServiceInterface; |
| 14 | use App\Modules\Mail\Domain\Service\EmailBlockRegistry; |
| 15 | use PDO; |
| 16 | use Psr\Http\Message\ResponseFactoryInterface; |
| 17 | use Psr\Http\Message\ResponseInterface; |
| 18 | use Psr\Http\Message\ServerRequestInterface; |
| 19 | use Throwable; |
| 20 | use 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 | */ |
| 30 | final 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 | } |