Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.35% covered (success)
94.35%
267 / 283
63.16% covered (warning)
63.16%
12 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
LanguageTranslationsApiController
94.68% covered (success)
94.68%
267 / 282
63.16% covered (warning)
63.16%
12 / 19
67.68
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
 list
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
3
 update
94.87% covered (success)
94.87%
37 / 39
0.00% covered (danger)
0.00%
0 / 1
6.00
 create
96.15% covered (success)
96.15%
50 / 52
0.00% covered (danger)
0.00%
0 / 1
6
 delete
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
4.02
 validateUpdateRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 validateCreateRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 validateDeleteRequest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 buildListWhereClause
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
7.34
 mapTranslationRows
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 findLanguage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 findSource
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 keyExists
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 calculateLanguageStats
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
 fetchCategories
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 invalidateCache
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 parsePayload
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 jsonResponse
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 errorResponse
100.00% covered (success)
100.00%
1 / 1
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\Translation\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\PermissionContext;
12use App\Shared\Infrastructure\Http\ApiResponseTrait;
13use PDO;
14use Psr\Http\Message\ResponseFactoryInterface;
15use Psr\Http\Message\ResponseInterface;
16use Psr\Http\Message\ServerRequestInterface;
17use Throwable;
18
19/**
20 * REST and HTMX API Controller for Language Translations Management.
21 *
22 * Provides endpoints for searching, listing, adding, inline-editing, and resetting
23 * translation keys and language messages within the 8/12 language details panel.
24 *
25 * Handled routes:
26 *   GET    /htmx/engine/system_languages/{id}/translations
27 *   POST   /htmx/engine/system_languages/{id}/translations
28 *   PUT    /htmx/engine/system_languages/{id}/translations/{sourceId}
29 *   DELETE /htmx/engine/system_languages/{id}/translations/{sourceId}
30 *
31 * @package App\Core\Translation\Presentation\Api
32 */
33final readonly class LanguageTranslationsApiController
34{
35    use ApiResponseTrait;
36
37    private const int DEFAULT_PER_PAGE = 50;
38    private const int MAX_PER_PAGE = 200;
39    private const string ERROR_LANGUAGE_NOT_FOUND = 'Language not found.';
40
41    /**
42     * LanguageTranslationsApiController constructor.
43     *
44     * @param ResponseFactoryInterface $responseFactory PSR-7 Response factory.
45     * @param PDO                      $pdo             PDO database connection instance.
46     * @param string                   $cacheDir        Directory for compiled translation cache files.
47     * @param string                   $tablePrefix     Database table prefix.
48     */
49    public function __construct(
50        private ResponseFactoryInterface $responseFactory,
51        private PDO                      $pdo,
52        private string                   $cacheDir = '',
53        private string                   $tablePrefix = 'a_',
54    ) {
55    }
56
57    /**
58     * Lists translations for a specific language with search, filtering and stats.
59     *
60     * @param ServerRequestInterface $request    PSR-7 Server request.
61     * @param int                    $languageId Language primary key.
62     * @return ResponseInterface JSON response.
63     */
64    public function list(ServerRequestInterface $request, int $languageId): ResponseInterface
65    {
66        try {
67            $lang = $this->findLanguage($languageId);
68            if ($lang === null) {
69                return $this->errorResponse(self::ERROR_LANGUAGE_NOT_FOUND, 404);
70            }
71
72            $code = (string) $lang['code'];
73            $params = $request->getQueryParams();
74            $q = trim((string) ($params['search'] ?? $params['q'] ?? ''));
75            $category = trim((string) ($params['category'] ?? ''));
76            $status = strtolower(trim((string) ($params['status'] ?? 'all')));
77            $page = max(1, (int) ($params['page'] ?? 1));
78            $perPage = min(self::MAX_PER_PAGE, max(10, (int) ($params['per_page'] ?? self::DEFAULT_PER_PAGE)));
79            $offset = ($page - 1) * $perPage;
80
81            $stats = $this->calculateLanguageStats($code);
82            $categories = $this->fetchCategories();
83
84            [$whereSql, $binds] = $this->buildListWhereClause($code, $category, $q, $status);
85            $sourceTable = $this->tablePrefix . 'core_translation_source_records';
86            $msgTable = $this->tablePrefix . 'core_translation_message_records';
87
88            $countSql = "SELECT COUNT(*) FROM `{$sourceTable}` s "
89                . "LEFT JOIN `{$msgTable}` m ON (m.source_id = s.id AND m.language_code = :lang_code) "
90                . "WHERE {$whereSql}";
91
92            $countStmt = $this->pdo->prepare($countSql);
93            $countStmt->execute($binds);
94            $totalItems = (int) $countStmt->fetchColumn();
95            $totalPages = max(1, (int) ceil($totalItems / $perPage));
96
97            $dataSql = "SELECT s.id AS source_id, s.category, s.message_key, s.default_message, s.description, "
98                . "m.id AS message_id, m.translation, m.is_custom, m.updated_at "
99                . "FROM `{$sourceTable}` s "
100                . "LEFT JOIN `{$msgTable}` m ON (m.source_id = s.id AND m.language_code = :lang_code) "
101                . "WHERE {$whereSql} "
102                . "ORDER BY s.category ASC, s.message_key ASC "
103                . "LIMIT {$perPage} OFFSET {$offset}";
104
105            $dataStmt = $this->pdo->prepare($dataSql);
106            $dataStmt->execute($binds);
107            $rawRows = $dataStmt->fetchAll(PDO::FETCH_ASSOC);
108
109            $items = $this->mapTranslationRows($rawRows, $code);
110
111            return $this->jsonResponse([
112                'success'    => true,
113                'language'   => $lang,
114                'items'      => $items,
115                'pagination' => [
116                    'current_page' => $page,
117                    'per_page'     => $perPage,
118                    'total_items'  => $totalItems,
119                    'total_pages'  => $totalPages,
120                ],
121                'stats'      => $stats,
122                'categories' => $categories,
123            ]);
124        } catch (Throwable $e) {
125            return $this->errorResponse($e->getMessage(), 500);
126        }
127    }
128
129    /**
130     * Updates translation message for a specific language and translation source key.
131     *
132     * @param ServerRequestInterface $request    PSR-7 Server request.
133     * @param int                    $languageId Language primary key.
134     * @param int                    $sourceId   Source record primary key.
135     * @param PermissionContext      $ctx        Actor permission context.
136     * @return ResponseInterface JSON response.
137     */
138    public function update(
139        ServerRequestInterface $request,
140        int                    $languageId,
141        int                    $sourceId,
142        PermissionContext      $ctx,
143    ): ResponseInterface {
144        try {
145            $lang = $this->findLanguage($languageId);
146            $source = $lang !== null ? $this->findSource($sourceId) : null;
147            $payload = $this->parsePayload($request);
148
149            $validationError = $this->validateUpdateRequest($lang, $source, $payload);
150            if ($validationError !== null) {
151                return $validationError;
152            }
153
154            /** @var array<string, mixed> $lang */
155            /** @var array<string, mixed> $source */
156            $translation = trim((string) $payload['translation']);
157            $code = (string) $lang['code'];
158            $userId = $ctx->userId > 0 ? $ctx->userId : 1;
159            $category = (string) $source['category'];
160
161            if ($code === 'en') {
162                $srcSql = "UPDATE `{$this->tablePrefix}core_translation_source_records` "
163                    . "SET `default_message` = :msg, `updated_at` = NOW(6) "
164                    . "WHERE `id` = :id";
165                $srcStmt = $this->pdo->prepare($srcSql);
166                $srcStmt->execute([':msg' => $translation, ':id' => $sourceId]);
167            }
168
169            $msgSql = "INSERT INTO `{$this->tablePrefix}core_translation_message_records` "
170                . "(`source_id`, `language_code`, `translation`, `is_custom`, `created_by`, `owner`) "
171                . "VALUES (:source_id, :code, :translation, 1, :created_by, :owner) "
172                . "ON DUPLICATE KEY UPDATE `translation` = VALUES(`translation`), "
173                . "`is_custom` = 1, `updated_at` = NOW(6)";
174
175            $msgStmt = $this->pdo->prepare($msgSql);
176            $msgStmt->execute([
177                ':source_id'   => $sourceId,
178                ':code'        => $code,
179                ':translation' => $translation,
180                ':created_by'  => $userId,
181                ':owner'       => $userId,
182            ]);
183
184            $this->invalidateCache($code, $category);
185
186            return $this->jsonResponse([
187                'success'     => true,
188                'message'     => 'Translation saved successfully.',
189                'translation' => $translation,
190                'source_id'   => $sourceId,
191                'stats'       => $this->calculateLanguageStats($code),
192            ]);
193        } catch (Throwable $e) {
194            return $this->errorResponse($e->getMessage(), 500);
195        }
196    }
197
198    /**
199     * Creates a new translation source key and message.
200     *
201     * @param ServerRequestInterface $request    PSR-7 Server request.
202     * @param int                    $languageId Language primary key.
203     * @param PermissionContext      $ctx        Actor permission context.
204     * @return ResponseInterface JSON response.
205     */
206    public function create(
207        ServerRequestInterface $request,
208        int                    $languageId,
209        PermissionContext      $ctx,
210    ): ResponseInterface {
211        try {
212            $lang = $this->findLanguage($languageId);
213            $payload = $this->parsePayload($request);
214            $category = strtolower(trim((string) ($payload['category'] ?? '')));
215            $key = trim((string) ($payload['message_key'] ?? ''));
216            $defaultMsg = trim((string) ($payload['default_message'] ?? ''));
217            $translation = trim((string) ($payload['translation'] ?? ''));
218            $description = trim((string) ($payload['description'] ?? ''));
219
220            $validationError = $this->validateCreateRequest($lang, $category, $key, $defaultMsg);
221            if ($validationError !== null) {
222                return $validationError;
223            }
224
225            /** @var array<string, mixed> $lang */
226            $code = (string) $lang['code'];
227            $userId = $ctx->userId > 0 ? $ctx->userId : 1;
228
229            $srcSql = "INSERT INTO `{$this->tablePrefix}core_translation_source_records` "
230                . "(`category`, `message_key`, `default_message`, `description`, `created_by`, `owner`) "
231                . "VALUES (:category, :key, :default_msg, :desc, :created_by, :owner)";
232
233            $srcStmt = $this->pdo->prepare($srcSql);
234            $srcStmt->execute([
235                ':category'    => $category,
236                ':key'         => $key,
237                ':default_msg' => $defaultMsg,
238                ':desc'        => $description !== '' ? $description : null,
239                ':created_by'  => $userId,
240                ':owner'       => $userId,
241            ]);
242
243            $sourceId = (int) $this->pdo->lastInsertId();
244
245            if ($translation !== '') {
246                $msgSql = "INSERT INTO `{$this->tablePrefix}core_translation_message_records` "
247                    . "(`source_id`, `language_code`, `translation`, `is_custom`, `created_by`, `owner`) "
248                    . "VALUES (:source_id, :code, :translation, 1, :created_by, :owner)";
249                $msgStmt = $this->pdo->prepare($msgSql);
250                $msgStmt->execute([
251                    ':source_id'   => $sourceId,
252                    ':code'        => $code,
253                    ':translation' => $translation,
254                    ':created_by'  => $userId,
255                    ':owner'       => $userId,
256                ]);
257            }
258
259            $this->invalidateCache($code, $category);
260
261            return $this->jsonResponse([
262                'success'   => true,
263                'message'   => 'Translation key created successfully.',
264                'source_id' => $sourceId,
265                'data'      => [
266                    'category'        => $category,
267                    'message_key'     => $key,
268                    'default_message' => $defaultMsg,
269                    'translation'     => $translation,
270                ],
271                'stats'     => $this->calculateLanguageStats($code),
272            ], 201);
273        } catch (Throwable $e) {
274            return $this->errorResponse($e->getMessage(), 500);
275        }
276    }
277
278    /**
279     * Resets or deletes custom translation message for a language and source key.
280     *
281     * @param int $languageId Language primary key.
282     * @param int $sourceId   Source record primary key.
283     * @return ResponseInterface JSON response.
284     */
285    public function delete(int $languageId, int $sourceId): ResponseInterface
286    {
287        try {
288            $lang = $this->findLanguage($languageId);
289            $source = $lang !== null ? $this->findSource($sourceId) : null;
290
291            $validationError = $this->validateDeleteRequest($lang, $source);
292            if ($validationError !== null) {
293                return $validationError;
294            }
295
296            /** @var array<string, mixed> $lang */
297            /** @var array<string, mixed> $source */
298            $code = (string) $lang['code'];
299            $category = (string) $source['category'];
300
301            $delSql = "DELETE FROM `{$this->tablePrefix}core_translation_message_records` "
302                . "WHERE `source_id` = :source_id AND `language_code` = :code";
303            $delStmt = $this->pdo->prepare($delSql);
304            $delStmt->execute([':source_id' => $sourceId, ':code' => $code]);
305
306            $this->invalidateCache($code, $category);
307
308            return $this->jsonResponse([
309                'success' => true,
310                'message' => 'Custom translation reset successfully.',
311                'stats'   => $this->calculateLanguageStats($code),
312            ]);
313        } catch (Throwable $e) {
314            return $this->errorResponse($e->getMessage(), 500);
315        }
316    }
317
318    /**
319     * Validates prerequisites and payload for translation update request.
320     *
321     * @param array<string, mixed>|null $lang    Language record or null.
322     * @param array<string, mixed>|null $source  Source record or null.
323     * @param array<string, mixed>      $payload Request payload data.
324     * @return ResponseInterface|null Error response if validation fails, null otherwise.
325     */
326    private function validateUpdateRequest(
327        ?array $lang,
328        ?array $source,
329        array  $payload,
330    ): ?ResponseInterface {
331        if ($lang === null) {
332            return $this->errorResponse(self::ERROR_LANGUAGE_NOT_FOUND, 404);
333        }
334        if ($source === null) {
335            return $this->errorResponse('Translation source key not found.', 404);
336        }
337
338        return !array_key_exists('translation', $payload)
339            ? $this->errorResponse('Field "translation" is required.', 400)
340            : null;
341    }
342
343    /**
344     * Validates prerequisites and payload for translation creation request.
345     *
346     * @param array<string, mixed>|null $lang       Language record or null.
347     * @param string                    $category   Translation category.
348     * @param string                    $key        Translation key.
349     * @param string                    $defaultMsg English default message.
350     * @return ResponseInterface|null Error response if validation fails, null otherwise.
351     */
352    private function validateCreateRequest(
353        ?array $lang,
354        string $category,
355        string $key,
356        string $defaultMsg,
357    ): ?ResponseInterface {
358        if ($lang === null) {
359            return $this->errorResponse(self::ERROR_LANGUAGE_NOT_FOUND, 404);
360        }
361        if ($category === '' || $key === '' || $defaultMsg === '') {
362            return $this->errorResponse('Category, message key, and English reference are required.', 400);
363        }
364
365        return $this->keyExists($category, $key)
366            ? $this->errorResponse("Key '{$key}' already exists in category '{$category}'.", 409)
367            : null;
368    }
369
370    /**
371     * Validates language and source existence for translation deletion request.
372     *
373     * @param array<string, mixed>|null $lang   Language record or null.
374     * @param array<string, mixed>|null $source Source record or null.
375     * @return ResponseInterface|null Error response if validation fails, null otherwise.
376     */
377    private function validateDeleteRequest(?array $lang, ?array $source): ?ResponseInterface
378    {
379        if ($lang === null) {
380            return $this->errorResponse(self::ERROR_LANGUAGE_NOT_FOUND, 404);
381        }
382        if ($source === null) {
383            return $this->errorResponse('Translation source key not found.', 404);
384        }
385
386        return null;
387    }
388
389    /**
390     * Builds WHERE clause SQL and parameter bindings for translation listing query.
391     *
392     * @param string $code     Language ISO code.
393     * @param string $category Translation category filter.
394     * @param string $q        Search keyword filter.
395     * @param string $status   Translation status filter ('all', 'translated', 'missing').
396     * @return array{0: string, 1: array<string, mixed>} Tuple of WHERE SQL and parameter bindings.
397     */
398    private function buildListWhereClause(
399        string $code,
400        string $category,
401        string $q,
402        string $status,
403    ): array {
404        $whereParts = ['1=1'];
405        $binds = [':lang_code' => $code];
406
407        if ($category !== '') {
408            $whereParts[] = 's.category = :category';
409            $binds[':category'] = $category;
410        }
411
412        if ($q !== '') {
413            $whereParts[] = '(s.message_key LIKE :q_key OR s.default_message LIKE :q_def '
414                . 'OR m.translation LIKE :q_trans)';
415            $like = '%' . $q . '%';
416            $binds[':q_key'] = $like;
417            $binds[':q_def'] = $like;
418            $binds[':q_trans'] = $like;
419        }
420
421        if ($status === 'translated') {
422            $whereParts[] = ($code === 'en')
423                ? 's.default_message != \'\''
424                : '(m.translation IS NOT NULL AND m.translation != \'\')';
425        } elseif ($status === 'missing') {
426            $whereParts[] = ($code === 'en')
427                ? 's.default_message = \'\''
428                : '(m.translation IS NULL OR m.translation = \'\')';
429        }
430
431        return [implode(' AND ', $whereParts), $binds];
432    }
433
434    /**
435     * Maps raw database rows into structured translation response items.
436     *
437     * @param list<array<string, mixed>> $rawRows Database rows.
438     * @param string                     $code    Language ISO code.
439     * @return list<array<string, mixed>> Formatted response items.
440     */
441    private function mapTranslationRows(array $rawRows, string $code): array
442    {
443        return array_map(static function (array $r) use ($code): array {
444            $def = (string) $r['default_message'];
445            $msg = (string) ($r['translation'] ?? '');
446            $activeTrans = $msg;
447            if ($activeTrans === '' && $code === 'en') {
448                $activeTrans = $def;
449            }
450            $isTranslated = $activeTrans !== '';
451
452            return [
453                'source_id'          => (int) $r['source_id'],
454                'category'           => (string) $r['category'],
455                'message_key'        => (string) $r['message_key'],
456                'default_message'    => $def,
457                'en_message'         => $def,
458                'description'        => $r['description'] !== null ? (string) $r['description'] : null,
459                'message_id'         => $r['message_id'] !== null ? (int) $r['message_id'] : null,
460                'translation'        => $activeTrans,
461                'active_translation' => $activeTrans,
462                'has_translation'    => $isTranslated ? 1 : 0,
463                'is_translated'      => $isTranslated,
464                'is_custom'          => (int) ($r['is_custom'] ?? 0) === 1,
465            ];
466        }, $rawRows);
467    }
468
469    /**
470     * Finds language record by primary key ID.
471     *
472     * @param int $id Language primary key.
473     * @return array<string, mixed>|null Language row or null.
474     */
475    private function findLanguage(int $id): ?array
476    {
477        $sql = "SELECT `id`, `code`, `name`, `native_name`, `flag_icon`, `is_default`, `is_active` "
478            . "FROM `{$this->tablePrefix}core_language_records` WHERE `id` = :id LIMIT 1";
479
480        $stmt = $this->pdo->prepare($sql);
481        $stmt->execute([':id' => $id]);
482        $row = $stmt->fetch(PDO::FETCH_ASSOC);
483
484        return is_array($row) ? $row : null;
485    }
486
487    /**
488     * Finds translation source record by primary key ID.
489     *
490     * @param int $id Translation source primary key.
491     * @return array<string, mixed>|null Source row or null.
492     */
493    private function findSource(int $id): ?array
494    {
495        $sql = "SELECT `id`, `category`, `message_key`, `default_message` "
496            . "FROM `{$this->tablePrefix}core_translation_source_records` WHERE `id` = :id LIMIT 1";
497
498        $stmt = $this->pdo->prepare($sql);
499        $stmt->execute([':id' => $id]);
500        $row = $stmt->fetch(PDO::FETCH_ASSOC);
501
502        return is_array($row) ? $row : null;
503    }
504
505    /**
506     * Checks if a key already exists in a given category.
507     *
508     * @param string $category Translation category name.
509     * @param string $key      Translation message key.
510     * @return bool True if key exists.
511     */
512    private function keyExists(string $category, string $key): bool
513    {
514        $sql = "SELECT `id` FROM `{$this->tablePrefix}core_translation_source_records` "
515            . "WHERE `category` = :cat AND `message_key` = :key LIMIT 1";
516
517        $stmt = $this->pdo->prepare($sql);
518        $stmt->execute([':cat' => $category, ':key' => $key]);
519
520        return (bool) $stmt->fetchColumn();
521    }
522
523    /**
524     * Calculates completion statistics for a given language code.
525     *
526     * @param string $code Language code.
527     * @return array{total_keys: int, translated_keys: int, percentage: float} Statistics.
528     */
529    private function calculateLanguageStats(string $code): array
530    {
531        $sourceTable = $this->tablePrefix . 'core_translation_source_records';
532        $msgTable = $this->tablePrefix . 'core_translation_message_records';
533
534        if ($code === 'en') {
535            $sql = "SELECT COUNT(*) AS total_keys, "
536                . "COUNT(CASE WHEN default_message IS NOT NULL AND default_message != '' "
537                . "THEN 1 END) AS translated_keys FROM `{$sourceTable}`";
538            $stmt = $this->pdo->prepare($sql);
539            $stmt->execute();
540        } else {
541            $sql = "SELECT COUNT(s.id) AS total_keys, "
542                . "COUNT(CASE WHEN m.translation IS NOT NULL AND m.translation != '' "
543                . "THEN 1 END) AS translated_keys FROM `{$sourceTable}` s "
544                . "LEFT JOIN `{$msgTable}` m ON (m.source_id = s.id AND m.language_code = :code)";
545            $stmt = $this->pdo->prepare($sql);
546            $stmt->execute([':code' => $code]);
547        }
548
549        $row = $stmt->fetch(PDO::FETCH_ASSOC);
550        $total = (int) ($row['total_keys'] ?? 0);
551        $translated = (int) ($row['translated_keys'] ?? 0);
552        $percentage = $total > 0 ? round(($translated / $total) * 100, 1) : 0.0;
553
554        return [
555            'total_keys'      => $total,
556            'translated_keys' => $translated,
557            'percentage'      => $percentage,
558        ];
559    }
560
561    /**
562     * Fetches distinct translation category list.
563     *
564     * @return list<string> Categories list.
565     */
566    private function fetchCategories(): array
567    {
568        $sql = "SELECT DISTINCT `category` FROM `{$this->tablePrefix}core_translation_source_records` "
569            . "ORDER BY `category` ASC";
570
571        $stmt = $this->pdo->prepare($sql);
572        $stmt->execute();
573
574        /** @var list<string> */
575        return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
576    }
577
578    /**
579     * Invalidates compiled translation cache files on disk.
580     *
581     * @param string $locale   Language code (e.g. 'pl', 'en').
582     * @param string $category Translation category (e.g. 'app', 'module').
583     */
584    private function invalidateCache(string $locale, string $category): void
585    {
586        if ($this->cacheDir === '' || !is_dir($this->cacheDir)) {
587            return;
588        }
589
590        $filePath = rtrim($this->cacheDir, '/\\') . '/' . $locale . '_' . $category . '.php';
591        if (is_file($filePath)) {
592            @unlink($filePath);
593        }
594    }
595
596    /**
597     * Parses request payload from parsed body or raw JSON input.
598     *
599     * @param ServerRequestInterface $request Incoming PSR-7 request.
600     * @return array<string, mixed> Parsed parameters map.
601     */
602    private function parsePayload(ServerRequestInterface $request): array
603    {
604        $parsed = $request->getParsedBody();
605        if (is_array($parsed) && !empty($parsed)) {
606            return $parsed;
607        }
608
609        $raw = (string) $request->getBody();
610        if ($raw === '') {
611            return [];
612        }
613
614        $decoded = json_decode($raw, true);
615
616        return is_array($decoded) ? $decoded : [];
617    }
618
619    /**
620     * Creates a standardized JSON response.
621     *
622     * @param mixed $data   Data payload.
623     * @param int   $status HTTP status code.
624     * @return ResponseInterface Formatted JSON response.
625     */
626    private function jsonResponse(mixed $data, int $status = 200): ResponseInterface
627    {
628        return $this->buildJsonResponse($this->responseFactory, $data, $status);
629    }
630
631    /**
632     * Creates a standardized error JSON response.
633     *
634     * @param string $message Error message.
635     * @param int    $status  HTTP status code.
636     * @return ResponseInterface JSON error response.
637     */
638    private function errorResponse(string $message, int $status): ResponseInterface
639    {
640        return $this->jsonResponse(['success' => false, 'error' => $message], $status);
641    }
642}