Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
104 / 104
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
ModuleRelationsApiController
100.00% covered (success)
100.00%
103 / 103
100.00% covered (success)
100.00%
11 / 11
20
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
 listFilters
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 deleteFilter
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 availableFilters
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 availablePicklists
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 listPicklists
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 deletePicklist
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 listGridRelations
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 deleteGridRelation
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 invalidateCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonResponse
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\Engine\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
12use App\Shared\Infrastructure\Http\ApiResponseTrait;
13use Nyholm\Psr7\Factory\Psr17Factory;
14use PDO;
15use Psr\Http\Message\ResponseInterface;
16
17/**
18 * Module Relations (Filters & Picklists) Management REST/HTMX API Controller.
19 *
20 * Provides JSON endpoints for related tabs (Filters, Picklists) attached to a module.
21 *
22 * Routes:
23 *   GET    /htmx/engine/system_modules/{id}/filters          -> listFilters()
24 *   DELETE /htmx/engine/system_modules/{id}/filters/{id}     -> deleteFilter()
25 *   GET    /htmx/engine/system_modules/{id}/picklists        -> listPicklists()
26 *   DELETE /htmx/engine/system_modules/{id}/picklists/{id}   -> deletePicklist()
27 *
28 * @package App\Core\Engine\Presentation\Api
29 */
30final readonly class ModuleRelationsApiController
31{
32    use ApiResponseTrait;
33
34    private const string ERR_SYSTEM_RECORD = 'System records cannot be deleted.';
35    private const string ERR_NOT_FOUND = 'Record not found.';
36
37    /**
38     * ModuleRelationsApiController constructor.
39     *
40     * @param PDO                         $pdo          Database connection.
41     * @param Psr17Factory                $psr17        PSR-17 response factory.
42     * @param MetadataRepositoryInterface|null $metadataRepo Optional metadata repository for cache invalidation.
43     */
44    public function __construct(
45        private PDO                          $pdo,
46        private Psr17Factory                 $psr17,
47        private ?MetadataRepositoryInterface $metadataRepo = null,
48    ) {
49    }
50
51    /**
52     * Lists all filter configurations belonging to a specific module.
53     *
54     * @param int $moduleId Module primary key.
55     * @return ResponseInterface JSON response with filters array.
56     */
57    public function listFilters(int $moduleId): ResponseInterface
58    {
59        $sql = 'SELECT f.`id`, f.`module_id`, f.`name`, f.`label`, f.`visible_fields`, ' .
60               'f.`default_sort`, f.`default_order`, f.`per_page`, f.`is_default`, f.`is_system`, ' .
61               'f.`scope`, f.`created_at`, f.`updated_at` ' .
62               'FROM `a_core_filter_records` f ' .
63               'WHERE f.`module_id` = :module_id ' .
64               'ORDER BY f.`is_default` DESC, f.`name` ASC';
65
66        $stmt = $this->pdo->prepare($sql);
67        $stmt->execute([':module_id' => $moduleId]);
68        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
69
70        return $this->jsonResponse(['success' => true, 'data' => $rows]);
71    }
72
73    /**
74     * Deletes a non-system filter from a module.
75     *
76     * @param int $moduleId Module primary key.
77     * @param int $filterId Filter primary key.
78     * @return ResponseInterface JSON response.
79     */
80    public function deleteFilter(int $moduleId, int $filterId): ResponseInterface
81    {
82        $stmt = $this->pdo->prepare(
83            'SELECT `id`, `is_system` FROM `a_core_filter_records` WHERE `id` = :id AND `module_id` = :module_id'
84        );
85        $stmt->execute([':id' => $filterId, ':module_id' => $moduleId]);
86        $row = $stmt->fetch(PDO::FETCH_ASSOC);
87
88        if ($row === false) {
89            return $this->jsonResponse(['success' => false, 'error' => self::ERR_NOT_FOUND], 404);
90        }
91
92        if (!empty($row['is_system'])) {
93            return $this->jsonResponse(['success' => false, 'error' => self::ERR_SYSTEM_RECORD], 403);
94        }
95
96        $deleteStmt = $this->pdo->prepare(
97            'DELETE FROM `a_core_filter_records` WHERE `id` = :id AND `module_id` = :module_id'
98        );
99        $deleteStmt->execute([':id' => $filterId, ':module_id' => $moduleId]);
100        $this->invalidateCache($moduleId);
101
102        return $this->jsonResponse(['success' => true, 'data' => ['deleted_id' => $filterId]]);
103    }
104
105    /**
106     * Lists available filter configurations selectable for module relations.
107     *
108     * @param int $moduleId Module primary key.
109     * @return ResponseInterface JSON response with selectable filters.
110     */
111    public function availableFilters(int $moduleId): ResponseInterface
112    {
113        $sql = 'SELECT f.`id`, f.`name`, f.`label` ' .
114               'FROM `a_core_filter_records` f ' .
115               'WHERE f.`module_id` = :module_id ' .
116               'ORDER BY f.`name` ASC';
117
118        $stmt = $this->pdo->prepare($sql);
119        $stmt->execute([':module_id' => $moduleId]);
120        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
121
122        return $this->jsonResponse(['success' => true, 'data' => $rows]);
123    }
124
125    /**
126     * Lists available picklists selectable for module relations.
127     *
128     * @param int $moduleId Module primary key.
129     * @return ResponseInterface JSON response with selectable picklists.
130     */
131    public function availablePicklists(int $moduleId): ResponseInterface
132    {
133        $sql = 'SELECT p.`id`, p.`name`, p.`label` ' .
134               'FROM `a_core_picklist_records` p ' .
135               'WHERE p.`is_active` = 1 AND (p.`module_id` = :module_id OR p.`module_id` IS NULL) ' .
136               'ORDER BY p.`label` ASC';
137
138        $stmt = $this->pdo->prepare($sql);
139        $stmt->execute([':module_id' => $moduleId]);
140        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
141
142        return $this->jsonResponse(['success' => true, 'data' => $rows]);
143    }
144
145    /**
146     * Lists all picklists belonging to a specific module.
147     *
148     * @param int $moduleId Module primary key.
149     * @return ResponseInterface JSON response with picklists array.
150     */
151    public function listPicklists(int $moduleId): ResponseInterface
152    {
153        $sql = 'SELECT p.`id`, p.`module_id`, p.`name`, p.`label`, p.`description`, ' .
154               'p.`is_active`, p.`is_system`, p.`created_at`, p.`updated_at`, ' .
155               'COUNT(v.`id`) AS `values_count` ' .
156               'FROM `a_core_picklist_records` p ' .
157               'LEFT JOIN `a_core_picklist_value_records` v ON v.`picklist_id` = p.`id` ' .
158               'WHERE p.`module_id` = :module_id ' .
159               'GROUP BY p.`id` ' .
160               'ORDER BY p.`name` ASC';
161
162        $stmt = $this->pdo->prepare($sql);
163        $stmt->execute([':module_id' => $moduleId]);
164        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
165
166        return $this->jsonResponse(['success' => true, 'data' => $rows]);
167    }
168
169    /**
170     * Deletes a non-system picklist from a module.
171     *
172     * @param int $moduleId   Module primary key.
173     * @param int $picklistId Picklist primary key.
174     * @return ResponseInterface JSON response.
175     */
176    public function deletePicklist(int $moduleId, int $picklistId): ResponseInterface
177    {
178        $stmt = $this->pdo->prepare(
179            'SELECT `id`, `is_system` FROM `a_core_picklist_records` WHERE `id` = :id AND `module_id` = :module_id'
180        );
181        $stmt->execute([':id' => $picklistId, ':module_id' => $moduleId]);
182        $row = $stmt->fetch(PDO::FETCH_ASSOC);
183
184        if ($row === false) {
185            return $this->jsonResponse(['success' => false, 'error' => self::ERR_NOT_FOUND], 404);
186        }
187
188        if (!empty($row['is_system'])) {
189            return $this->jsonResponse(['success' => false, 'error' => self::ERR_SYSTEM_RECORD], 403);
190        }
191
192        $deleteStmt = $this->pdo->prepare(
193            'DELETE FROM `a_core_picklist_records` WHERE `id` = :id AND `module_id` = :module_id'
194        );
195        $deleteStmt->execute([':id' => $picklistId, ':module_id' => $moduleId]);
196        $this->invalidateCache($moduleId);
197
198        return $this->jsonResponse(['success' => true, 'data' => ['deleted_id' => $picklistId]]);
199    }
200
201    /**
202     * Lists grid widget relations belonging to a specific module or widget.
203     *
204     * @param int|null $moduleId Module primary key filter.
205     * @param int|null $widgetId Widget primary key filter.
206     * @return array<int, array<string, mixed>> Grid relations records.
207     */
208    public function listGridRelations(?int $moduleId = null, ?int $widgetId = null): array
209    {
210        $sql = 'SELECT rg.`id`, rg.`name`, rg.`label`, rg.`module_id`, rg.`grid_filter_id`, ' .
211               'rg.`widget_id`, rg.`pos_x`, rg.`pos_y`, rg.`width`, rg.`height`, ' .
212               'rg.`is_locked`, rg.`widget_params`, rg.`is_active`, rg.`sort_order`, ' .
213               'rg.`special_access`, ' .
214               'm.`name` AS `module_name`, m.`label` AS `module_label`, m.`icon_class` AS `module_icon`, ' .
215               'w.`name` AS `widget_name`, w.`label` AS `widget_label`, w.`category` AS `widget_category` ' .
216               'FROM `a_core_relation_grid_records` rg ' .
217               'LEFT JOIN `a_core_module_records` m ON m.`id` = rg.`module_id` ' .
218               'LEFT JOIN `a_core_widget_records` w ON w.`id` = rg.`widget_id` ' .
219               'WHERE 1 = 1 ';
220
221        $params = [];
222        if ($moduleId !== null && $moduleId > 0) {
223            $sql .= 'AND rg.`module_id` = :module_id ';
224            $params[':module_id'] = $moduleId;
225        }
226
227        if ($widgetId !== null && $widgetId > 0) {
228            $sql .= 'AND rg.`widget_id` = :widget_id ';
229            $params[':widget_id'] = $widgetId;
230        }
231
232        $sql .= 'ORDER BY rg.`sort_order` ASC, rg.`pos_y` ASC, rg.`pos_x` ASC, rg.`id` ASC';
233
234        $stmt = $this->pdo->prepare($sql);
235        $stmt->execute($params);
236
237        /** @var array<int, array<string, mixed>> */
238        return $stmt->fetchAll(PDO::FETCH_ASSOC);
239    }
240
241    /**
242     * Deletes a grid relation placement record.
243     *
244     * @param int $relationId Relation grid primary key.
245     * @return bool True if record was deleted, false otherwise.
246     */
247    public function deleteGridRelation(int $relationId): bool
248    {
249        $stmt = $this->pdo->prepare(
250            'SELECT `id`, `module_id` FROM `a_core_relation_grid_records` WHERE `id` = :id'
251        );
252        $stmt->execute([':id' => $relationId]);
253        $row = $stmt->fetch(PDO::FETCH_ASSOC);
254
255        if ($row === false) {
256            return false;
257        }
258
259        $deleteStmt = $this->pdo->prepare('DELETE FROM `a_core_relation_grid_records` WHERE `id` = :id');
260        $deleteStmt->execute([':id' => $relationId]);
261
262        $this->invalidateCache((int) $row['module_id']);
263
264        return true;
265    }
266
267    use ModuleConfigApiControllerTrait;
268
269    /**
270     * Safely invalidates metadata repository cache when available.
271     *
272     * @param int|null $moduleId Optional module ID.
273     */
274    private function invalidateCache(?int $moduleId = null): void
275    {
276        $this->invalidateMetadataCache($this->metadataRepo, $moduleId);
277    }
278
279    /**
280     * Creates a standardized JSON response using ApiResponseTrait.
281     *
282     * @param array<string, mixed> $payload Response payload.
283     * @param int                  $status  HTTP status code.
284     * @return ResponseInterface PSR-7 response.
285     */
286    private function jsonResponse(array $payload, int $status = 200): ResponseInterface
287    {
288        return $this->buildJsonResponse($this->psr17, $payload, $status);
289    }
290}