Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.83% covered (warning)
89.83%
106 / 118
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
MenuApiController
89.74% covered (warning)
89.74%
105 / 117
63.64% covered (warning)
63.64%
7 / 11
46.09
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
 tree
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 serializeTree
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filterMenuTree
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 filterMenuItemsRecursively
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
7.07
 shouldIncludeItem
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 isModuleItemAccessible
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 resolveCurrentUser
46.67% covered (danger)
46.67%
7 / 15
0.00% covered (danger)
0.00%
0 / 1
25.17
 records
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 fetchGridResult
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createGrid
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
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\Menu\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\ModuleMetadata;
12use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
13use App\Core\Grid\Column;
14use App\Core\Grid\GenericDataGrid;
15use App\Core\Grid\GridRequest;
16use App\Core\Grid\GridResult;
17use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
18use App\Modules\Menu\Domain\Model\MenuItem;
19use App\Modules\Menu\Domain\Repository\MenuRepositoryInterface;
20use App\Modules\Profiles\Domain\Repository\ProfilePermissionRepositoryInterface;
21use App\Modules\Profiles\Infrastructure\Repository\SqlProfilePermissionRepository;
22use App\Shared\Infrastructure\Http\ApiResponseTrait;
23use PDO;
24use Psr\Http\Message\ResponseFactoryInterface;
25use Psr\Http\Message\ResponseInterface;
26use Psr\Http\Message\ServerRequestInterface;
27use Twig\Environment as TwigEnvironment;
28
29/**
30 * Menu Tree & Management REST API Controller.
31 *
32 * Exposes /api/v1/menu/tree and /api/v1/menu/records JSON endpoints.
33 *
34 * @package App\Modules\Menu\Presentation\Api
35 */
36final readonly class MenuApiController
37{
38    use ApiResponseTrait;
39
40    private const string CACHE_CONTROL_HEADER = 'private, max-age=300, must-revalidate';
41
42    /**
43     * MenuApiController constructor.
44     *
45     * @param ResponseFactoryInterface              $responseFactory PSR-7 Response factory.
46     * @param MenuRepositoryInterface               $menuRepository  Menu repository.
47     * @param PDO                                   $pdo             Database PDO connection.
48     * @param TwigEnvironment                       $twig            Twig template engine.
49     * @param string                                $tablePrefix     Database table prefix.
50     * @param MetadataRepositoryInterface|null      $metadataRepo    Metadata repository.
51     * @param InstanceContextManagerInterface|null  $instanceContextManager Context manager.
52     * @param string                                $appProfile      Application profile ('admin' or 'client').
53     */
54    private ProfilePermissionRepositoryInterface $profileRepo;
55
56    public function __construct(
57        private ResponseFactoryInterface $responseFactory,
58        private MenuRepositoryInterface $menuRepository,
59        private PDO $pdo,
60        private TwigEnvironment $twig,
61        private string $tablePrefix = 'a_',
62        private ?MetadataRepositoryInterface $metadataRepo = null,
63        private ?InstanceContextManagerInterface $instanceContextManager = null,
64        private string $appProfile = 'admin',
65        ?ProfilePermissionRepositoryInterface $profileRepo = null
66    ) {
67        $this->profileRepo = $profileRepo ?? new SqlProfilePermissionRepository($this->pdo, $this->tablePrefix);
68    }
69
70    /**
71     * Handles /api/v1/menu/tree REST API request.
72     *
73     * @param ServerRequestInterface|null $request Optional PSR-7 Server request.
74     * @return ResponseInterface PSR-7 JSON response.
75     */
76    public function tree(?ServerRequestInterface $request = null): ResponseInterface
77    {
78        $effectiveProfile = ($this->instanceContextManager !== null && $this->instanceContextManager->isRemote())
79            ? 'client'
80            : $this->appProfile;
81        $rawTree = $this->menuRepository->getActiveMenuTree($effectiveProfile);
82        $activeHostId = $effectiveProfile === 'client' ? 2 : 1;
83        $filteredTree = $this->filterMenuTree($rawTree, $activeHostId);
84
85        $tree = $this->serializeTree($filteredTree);
86        $json = json_encode([
87            'success' => true,
88            'data'    => $tree,
89            'tree'    => $tree,
90        ], JSON_THROW_ON_ERROR);
91
92        $etag = '"' . hash('sha256', $json) . '"';
93
94        if ($request !== null && $request->getHeaderLine('If-None-Match') === $etag) {
95            return $this->responseFactory->createResponse(304)
96                ->withHeader('ETag', $etag)
97                ->withHeader('Cache-Control', self::CACHE_CONTROL_HEADER);
98        }
99
100        $response = $this->responseFactory->createResponse(200);
101        $response->getBody()->write($json);
102
103        return $response
104            ->withHeader('Content-Type', 'application/json; charset=utf-8')
105            ->withHeader('ETag', $etag)
106            ->withHeader('Cache-Control', self::CACHE_CONTROL_HEADER);
107    }
108
109    /**
110     * Serializes menu tree items into array structures.
111     *
112     * @param list<MenuItem> $items Menu items.
113     * @return list<array<string, mixed>> Serialized menu items.
114     */
115    private function serializeTree(array $items): array
116    {
117        return array_map(static fn(MenuItem $item): array => $item->toArray(), $items);
118    }
119
120    /**
121     * Filters active menu tree items against host availability.
122     *
123     * @param list<MenuItem> $tree         Raw menu tree.
124     * @param int            $activeHostId Active host ID.
125     * @return list<MenuItem> Filtered menu tree.
126     */
127    private function filterMenuTree(array $tree, int $activeHostId): array
128    {
129        if ($this->metadataRepo === null) {
130            return $tree;
131        }
132
133        $allModules = $this->metadataRepo->findAllActiveModules();
134        $modulesByRoute = [];
135        foreach ($allModules as $mod) {
136            $modulesByRoute['/' . trim($mod->routeUrl, '/')] = $mod;
137        }
138
139        return $this->filterMenuItemsRecursively($tree, $modulesByRoute, $activeHostId);
140    }
141
142    /**
143     * Recursively traverses and filters menu items.
144     *
145     * @param list<MenuItem>                $items          Menu items.
146     * @param array<string, ModuleMetadata> $modulesByRoute Modules map.
147     * @param int                           $activeHostId   Host ID.
148     * @return list<MenuItem> Filtered items list.
149     */
150    private function filterMenuItemsRecursively(array $items, array $modulesByRoute, int $activeHostId): array
151    {
152        $result = [];
153        foreach ($items as $item) {
154            $type = $item->getType();
155            if ($type === 'group') {
156                $children = $item->getChildren();
157                $filteredChildren = empty($children)
158                    ? []
159                    : $this->filterMenuItemsRecursively($children, $modulesByRoute, $activeHostId);
160
161                if (!empty($filteredChildren)) {
162                    $result[] = $item->withChildren($filteredChildren);
163                }
164                continue;
165            }
166
167            if (!$this->shouldIncludeItem($item, $modulesByRoute, $activeHostId)) {
168                continue;
169            }
170
171            $children = $item->getChildren();
172            $filteredChildren = empty($children)
173                ? []
174                : $this->filterMenuItemsRecursively($children, $modulesByRoute, $activeHostId);
175
176            $result[] = $item->withChildren($filteredChildren);
177        }
178
179        return $result;
180    }
181
182    /**
183     * Checks whether menu item should be included based on active host availability.
184     *
185     * @param MenuItem                      $item           Menu item.
186     * @param array<string, ModuleMetadata> $modulesByRoute Modules map.
187     * @param int                           $activeHostId   Host ID.
188     * @return bool True if item should be included.
189     */
190    private function shouldIncludeItem(MenuItem $item, array $modulesByRoute, int $activeHostId): bool
191    {
192        $type = $item->getType();
193        if ($type === 'separator' || $type === 'divider') {
194            return true;
195        }
196
197        if ($type !== 'view' && $type !== 'module') {
198            return false;
199        }
200
201        return $this->isModuleItemAccessible($item, $modulesByRoute, $activeHostId);
202    }
203
204    /**
205     * Checks if module menu item is accessible for current host and user permissions.
206     *
207     * @param MenuItem                      $item           Menu item.
208     * @param array<string, ModuleMetadata> $modulesByRoute Modules map.
209     * @param int                           $activeHostId   Host ID.
210     * @return bool True if accessible.
211     */
212    private function isModuleItemAccessible(MenuItem $item, array $modulesByRoute, int $activeHostId): bool
213    {
214        $routeUrl = $item->getRouteUrl();
215        $normalized = $routeUrl !== null ? '/' . trim($routeUrl, '/') : '';
216
217        if (!isset($modulesByRoute[$normalized])) {
218            return true;
219        }
220
221        $module = $modulesByRoute[$normalized];
222        if (!$module->isAvailableForHost($activeHostId)) {
223            return false;
224        }
225
226        $user = $this->resolveCurrentUser();
227        $isSuperuser = (bool) ($user['is_superuser'] ?? false);
228        $profileId = $user['profile_id'] ?? null;
229
230        return $isSuperuser
231            || $profileId === null
232            || $this->profileRepo->canViewModule((int) $profileId, $module->name);
233    }
234
235    /**
236     * Handles /api/v1/menu/records REST API DataGrid endpoint.
237     *
238     * @param ServerRequestInterface $request PSR-7 Server request.
239     * @return ResponseInterface PSR-7 JSON response.
240     */
241    /**
242     * Resolves currently authenticated user from session.
243     *
244     * @return array<string, mixed> User data map.
245     */
246    private function resolveCurrentUser(): array
247    {
248        $user = [];
249        if (isset($_SESSION['user']) && is_array($_SESSION['user'])) {
250            $user = $_SESSION['user'];
251        }
252
253        if (
254            !empty($user['id'])
255            && (!isset($user['profile_id']) || $user['profile_id'] === null)
256            && empty($user['is_superuser'])
257        ) {
258            $userId = (int) $user['id'];
259            $table = ($this->appProfile === 'client') ? 'c_mod_users_records' : 'a_mod_users_records';
260            $stmt = $this->pdo->prepare("SELECT `profile_id` FROM `{$table}` WHERE `id` = :uid LIMIT 1");
261            $stmt->execute([':uid' => $userId]);
262            $col = $stmt->fetchColumn();
263            if ($col !== false && $col !== null) {
264                $user['profile_id'] = (int) $col;
265                $_SESSION['user']['profile_id'] = (int) $col;
266            }
267        }
268
269        return $user;
270    }
271
272    public function records(ServerRequestInterface $request): ResponseInterface
273    {
274        $gridRequest = GridRequest::fromRequest($request, 'sort_order');
275        $result = $this->createGrid()->fetchData($gridRequest);
276
277        $response = $this->responseFactory->createResponse(200);
278        $payload = [
279            'status' => true,
280            'page' => $result->gridRequest->page,
281            'limit' => $result->gridRequest->limit,
282            'totalRecords' => $result->totalRecords,
283            'totalPages' => $result->totalPages,
284            'rows' => $result->rows,
285        ];
286        $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_SLASHES));
287
288        return $response->withHeader('Content-Type', 'application/json');
289    }
290
291    /**
292     * Fetches GridResult container for web or internal API consumers.
293     *
294     * @param ServerRequestInterface $request PSR-7 Server request.
295     * @return GridResult DataGrid query result container.
296     */
297    public function fetchGridResult(ServerRequestInterface $request): GridResult
298    {
299        $gridRequest = GridRequest::fromRequest($request, 'sort_order');
300        return $this->createGrid()->fetchData($gridRequest);
301    }
302
303    /**
304     * Creates configured GenericDataGrid engine instance.
305     *
306     * @return GenericDataGrid DataGrid instance.
307     */
308    public function createGrid(): GenericDataGrid
309    {
310        $columns = [
311            new Column('id', 'ID', true, true),
312            new Column('parent_id', 'Parent ID', true, true),
313            new Column('type', 'Type', true, true, function (mixed $val): string {
314                return (string) ($val ?? '');
315            }),
316            new Column('label', 'Label', true, true),
317            new Column('route_url', 'Route URL', true, true),
318            new Column('icon_class', 'Icon', false, false, function (mixed $val): string {
319                return !empty($val) ? (string) $val : '-';
320            }),
321            new Column('sort_order', 'Sort Order', true, false),
322            new Column('is_active', 'Active', true, false, function (mixed $val): string {
323                return (bool) $val ? 'Active' : 'Disabled';
324            }),
325        ];
326
327        $tableName = $this->tablePrefix . 'mod_menu_records';
328        return new GenericDataGrid($this->pdo, $this->twig, $tableName, $columns, 'sort_order');
329    }
330}