Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.35% covered (success)
97.35%
110 / 113
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecordRelationsApiController
98.21% covered (success)
98.21%
110 / 112
87.50% covered (warning)
87.50%
7 / 8
36
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
 fetchHierarchy
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 actionHierarchy
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 actionToggleFavorite
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
4.04
 actionTogglePin
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 actionRelationOptions
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
12
 resolveRelationTargetModule
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
10
 handleException
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\Shared\Infrastructure\Http\ApiResponseTrait;
12use App\Core\Engine\Application\Service\HierarchyService;
13use App\Core\Engine\Application\Service\RecordFavoriteService;
14use App\Core\Engine\Application\Service\RecordPinService;
15use App\Core\Engine\Application\Service\UniversalCrudService;
16use App\Core\Engine\Domain\Exception\ModuleNotFoundException;
17use App\Core\Engine\Domain\Exception\PermissionDeniedException;
18use App\Core\Engine\Domain\Exception\RecordNotFoundException;
19use App\Modules\Structure\Domain\Exception\StructureReassignRequiredException;
20use App\Core\Engine\Domain\Exception\ValidationException;
21use App\Core\Engine\Domain\Model\FieldMetadata;
22use App\Core\Engine\Domain\Model\PermissionContext;
23use Nyholm\Psr7\Factory\Psr17Factory;
24use PDO;
25use Psr\Http\Message\ResponseInterface;
26use Psr\Http\Message\ServerRequestInterface;
27
28/**
29 * Handles record hierarchy, favorites, pin states, and relational option lookups.
30 */
31final readonly class RecordRelationsApiController
32{
33    use ApiResponseTrait;
34
35    public function __construct(
36        private UniversalCrudService $crudService,
37        private Psr17Factory $psr17,
38        private ?HierarchyService $hierarchyService = null,
39        private ?PDO $pdo = null,
40        private ?RecordFavoriteService $favoriteService = null,
41        private ?RecordPinService $pinService = null
42    ) {
43    }
44
45    /**
46     * Fetches hierarchy tree structure for a record.
47     *
48     * @param string            $moduleName Module machine name.
49     * @param int               $id         Record primary key.
50     * @param PermissionContext $context    Security context.
51     * @return array<string, mixed> Hierarchical tree payload.
52     */
53    public function fetchHierarchy(
54        string $moduleName,
55        int $id,
56        PermissionContext $context
57    ): array {
58        $meta = $this->crudService->getMetadataRepository();
59        $module = $meta->findModule($moduleName);
60        $this->crudService->getGuard()->assertReadAccess($module, $context);
61        $fields = $meta->findFields($module->id);
62
63        if ($this->hierarchyService === null) {
64            return ['has_hierarchy' => false, 'related_count' => 0, 'tree' => null];
65        }
66
67        return $this->hierarchyService->getHierarchy(
68            $module->name,
69            $id,
70            $module->tableName,
71            $fields,
72            $module->routeUrl
73        );
74    }
75
76    /**
77     * Handles GET /api/v1/engine/{module}/{id}/hierarchy.
78     *
79     * @param string            $moduleName Module machine name.
80     * @param int               $id         Record primary key.
81     * @param PermissionContext $context    Security context.
82     * @return ResponseInterface JSON response with tree payload.
83     */
84    public function actionHierarchy(
85        string $moduleName,
86        int $id,
87        PermissionContext $context
88    ): ResponseInterface {
89        try {
90            $data = $this->fetchHierarchy($moduleName, $id, $context);
91
92            return $this->jsonSuccess($this->psr17, array_merge([
93                'module'    => $moduleName,
94                'record_id' => $id,
95            ], $data));
96        } catch (\Throwable $e) {
97            return $this->handleException($e);
98        }
99    }
100
101    /**
102     * Handles POST /api/v1/engine/{module}/{id}/favorite.
103     *
104     * @param string            $moduleName Module machine name.
105     * @param int               $id         Record primary key.
106     * @param PermissionContext $context    Security context.
107     * @return ResponseInterface JSON response with new is_favorite state.
108     */
109    public function actionToggleFavorite(
110        string $moduleName,
111        int $id,
112        PermissionContext $context
113    ): ResponseInterface {
114        try {
115            $meta = $this->crudService->getMetadataRepository();
116            $module = $meta->findModule($moduleName);
117            $this->crudService->getGuard()->assertReadAccess($module, $context);
118
119            $favService = $this->favoriteService
120                ?? ($this->pdo !== null ? new RecordFavoriteService($this->pdo) : null);
121            if ($favService === null) {
122                return $this->jsonError($this->psr17, 'Favorite service unavailable.', 500);
123            }
124
125            $isFavorite = $favService->toggleFavorite($module->name, $id, $context->actorUserId);
126
127            return $this->jsonSuccess($this->psr17, [
128                'module'      => $module->name,
129                'record_id'   => $id,
130                'is_favorite' => $isFavorite,
131            ]);
132        } catch (\Throwable $e) {
133            return $this->handleException($e);
134        }
135    }
136
137    /**
138     * Handles POST /api/v1/engine/{module}/{id}/pin.
139     *
140     * @param string            $moduleName Module machine name.
141     * @param int               $id         Record primary key.
142     * @param PermissionContext $context    Security context.
143     * @return ResponseInterface JSON response with new is_pinned state.
144     */
145    public function actionTogglePin(
146        string $moduleName,
147        int $id,
148        PermissionContext $context
149    ): ResponseInterface {
150        try {
151            $meta = $this->crudService->getMetadataRepository();
152            $module = $meta->findModule($moduleName);
153            $this->crudService->getGuard()->assertWriteAccess($module, $context);
154
155            $pinService = $this->pinService
156                ?? ($this->pdo !== null ? new RecordPinService($this->pdo) : null);
157            if ($pinService === null) {
158                return $this->jsonError($this->psr17, 'Pin service unavailable.', 500);
159            }
160
161            $isPinned = $pinService->togglePin($module->tableName, $id);
162
163            return $this->jsonSuccess($this->psr17, [
164                'module'    => $module->name,
165                'record_id' => $id,
166                'is_pinned' => $isPinned,
167            ]);
168        } catch (\Throwable $e) {
169            return $this->handleException($e);
170        }
171    }
172
173    /**
174     * Handles GET /api/v1/engine/{module}/relation-options.
175     *
176     * @param ServerRequestInterface $request    PSR-7 request.
177     * @param string                 $moduleName Source module name.
178     * @param PermissionContext      $context    Security context.
179     * @return ResponseInterface JSON response with matching items.
180     */
181    public function actionRelationOptions(
182        ServerRequestInterface $request,
183        string $moduleName,
184        PermissionContext $context
185    ): ResponseInterface {
186        try {
187            $params = $request->getQueryParams();
188            $fieldKey = (string) ($params['field_key'] ?? '');
189            $query = trim((string) ($params['q'] ?? ''));
190            $rawRecordId = $params['record_id'] ?? null;
191            $recordId = $rawRecordId !== null && is_numeric($rawRecordId) ? (int) $rawRecordId : null;
192            $limit = max(1, min(100, (int) ($params['limit'] ?? 20)));
193
194            $meta = $this->crudService->getMetadataRepository();
195            $module = $meta->findModule($moduleName);
196            $this->crudService->getGuard()->assertReadAccess($module, $context);
197
198            $fields = $meta->findFields($module->id);
199            $targetField = null;
200            foreach ($fields as $f) {
201                if ($f->fieldKey === $fieldKey) {
202                    $targetField = $f;
203                    break;
204                }
205            }
206
207            if ($targetField === null) {
208                return $this->jsonError($this->psr17, 'Relation field not found.', 404);
209            }
210
211            $targetModuleName = $this->resolveRelationTargetModule(
212                $targetField,
213                $moduleName,
214                $params
215            );
216            $targetModule = $meta->findModule($targetModuleName);
217            $this->crudService->getGuard()->assertReadAccess($targetModule, $context);
218
219            $targetFields = $meta->findFields($targetModule->id);
220            $targetTable = $targetModule->tableName;
221
222            $excludedIds = [];
223            if ($recordId !== null && $recordId > 0 && $targetModuleName === $moduleName) {
224                $excludedIds = $this->hierarchyService !== null
225                    ? $this->hierarchyService->getExcludedDescendantIds($targetTable, $recordId)
226                    : [$recordId];
227            }
228
229            $items = $this->hierarchyService !== null
230                ? $this->hierarchyService->searchRelationOptions(
231                    $targetTable,
232                    $targetFields,
233                    $query,
234                    $excludedIds,
235                    $limit
236                )
237                : [];
238
239            return $this->jsonSuccess($this->psr17, [
240                'items'         => $items,
241                'total'         => count($items),
242                'target_module' => $targetModuleName,
243                'target_label'  => $targetModule->label,
244            ]);
245        } catch (\Throwable $e) {
246            return $this->handleException($e);
247        }
248    }
249
250    /**
251     * Resolves target module for relation queries supporting polymorphic configurations.
252     *
253     * @param FieldMetadata        $field         Field metadata.
254     * @param string               $defaultModule Default module fallback.
255     * @param array<string, mixed> $params        Query parameters.
256     * @return string Resolved target module name.
257     */
258    public function resolveRelationTargetModule(
259        FieldMetadata $field,
260        string $defaultModule,
261        array $params
262    ): string {
263        $rawTarget = trim((string) ($params['target_module'] ?? ''));
264        $allowed = $field->relationModule ?: $defaultModule;
265
266        if ($rawTarget !== '' && (str_contains($allowed, $rawTarget) || $allowed === $rawTarget)) {
267            return $rawTarget;
268        }
269
270        $resolved = $allowed;
271        if (str_starts_with($allowed, '[') && str_ends_with($allowed, ']')) {
272            $decoded = json_decode($allowed, true);
273            if (is_array($decoded) && !empty($decoded)) {
274                $resolved = (string) $decoded[0];
275            }
276        } elseif (str_contains($allowed, ',')) {
277            $parts = explode(',', $allowed);
278            $resolved = trim($parts[0]);
279        }
280
281        return $resolved;
282    }
283
284    /**
285     * Maps thrown domain exceptions to PSR-7 JSON responses.
286     */
287    private function handleException(\Throwable $e): ResponseInterface
288    {
289        return $this->handleApiException($this->psr17, $e);
290    }
291}