Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.24% covered (warning)
83.24%
298 / 358
55.56% covered (warning)
55.56%
15 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
SystemApiRouteGroup
83.19% covered (warning)
83.19%
297 / 357
55.56% covered (warning)
55.56%
15 / 27
608.97
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
 route
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 routeStructureEndpoints
52.63% covered (warning)
52.63%
10 / 19
0.00% covered (danger)
0.00%
0 / 1
43.21
 routeAccessEndpoints
60.87% covered (warning)
60.87%
14 / 23
0.00% covered (danger)
0.00%
0 / 1
47.42
 routeProfileEndpoints
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
0.00%
0 / 1
31.45
 routeSearchEndpoints
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
17.58
 routeCommentEndpoints
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 routeCommentMentionsEndpoints
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
6.60
 routeCommentMentionsActionEndpoints
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
10.86
 routeCommentItemEndpoints
46.67% covered (danger)
46.67%
7 / 15
0.00% covered (danger)
0.00%
0 / 1
33.85
 routeCoreEndpoints
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 routeSystemGeneralEndpoints
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
11
 routeAuditAndContextEndpoints
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
12
 routeUserAndDeviceEndpoints
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
31
 routeCronAndMailEndpoints
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 routeWebmailEndpoints
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
22
 routeMailServerEndpoints
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
10
 routeCronTaskEndpoints
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 routeWorkflowEndpoints
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 routeMapEndpoints
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
21
 routePdfEndpoints
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
15
 routeDashboardEndpoints
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 routeDashboardWidgetEndpoints
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 routeDashboardEventsWidgets
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 routeDashboardEventEndpoints
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 jsonError
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 routeModuleBuilderEndpoints
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
21.08
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\Api\Presentation\Group;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Access\Presentation\Api\AccessApiController;
12use App\Core\Audit\Presentation\Api\AuditApiController;
13use App\Core\Cron\Presentation\Api\CronApiController;
14use App\Core\Engine\Domain\Model\PermissionContext;
15use App\Core\Instance\Presentation\Api\InstanceContextApiController;
16use App\Core\Layout\Presentation\Api\LayoutApiController;
17use App\Core\ModuleBuilder\Presentation\Api\ModuleBuilderApiController;
18use App\Core\Preference\Presentation\Api\UserPreferencesApiController;
19use App\Core\Search\Presentation\Api\GlobalSearchApiController;
20use App\Core\Search\Presentation\Api\SearchSettingsApiController;
21use App\Core\Settings\Presentation\Api\SettingsApiController;
22use App\Core\Translation\Presentation\Api\TranslationApiController;
23use App\Modules\About\Presentation\Api\AboutLicensesApiController;
24use App\Modules\About\Presentation\Api\AboutRequirementsApiController;
25use App\Modules\Automation\Presentation\Api\WorkflowApiController;
26use App\Modules\Comments\Presentation\Api\CommentsApiController;
27use App\Modules\Dashboard\Presentation\Api\DashboardApiControllerInterface;
28use App\Modules\Dav\Presentation\Api\DavDeviceApiController;
29use App\Modules\Mail\Presentation\Api\MailApiController;
30use App\Modules\Mail\Presentation\Api\WebmailApiController;
31use App\Modules\Mail\Presentation\Api\WebmailEventsApiController;
32use App\Modules\Map\Presentation\Api\MapApiController;
33use App\Modules\Menu\Presentation\Api\MenuApiController;
34use App\Modules\Pdf\Presentation\Api\PdfApiController;
35use App\Modules\Profiles\Presentation\Api\ProfilePermissionApiController;
36use App\Modules\Structure\Presentation\Api\StructureApiController;
37use App\Modules\User\Presentation\Api\AuthApiController;
38use App\Modules\User\Presentation\Api\MfaApiController;
39use App\Modules\User\Presentation\Api\UserProfileApiController;
40use Psr\Http\Message\ResponseFactoryInterface;
41use Psr\Http\Message\ResponseInterface;
42use Psr\Http\Message\ServerRequestInterface;
43
44/**
45 * Handles REST API routing for Structure, Access, Profiles, Search, Mail, Cron, Workflows,
46 * Maps, PDF, Comments, Dashboard, and Core system endpoints.
47 */
48final readonly class SystemApiRouteGroup
49{
50    private const string PATH_USER_PREFERENCES = '/api/v1/user/preferences';
51    private const string PATH_SETTINGS         = '/api/v1/settings';
52
53    private ResponseFactoryInterface $factory;
54
55    public function __construct(
56        private ?AuthApiController $authApi = null,
57        private ?MenuApiController $menuApi = null,
58        private ?AboutLicensesApiController $aboutApi = null,
59        private ?AboutRequirementsApiController $aboutRequirementsApi = null,
60        private ?TranslationApiController $transApi = null,
61        private ?SettingsApiController $settingsApi = null,
62        private ?LayoutApiController $layoutApi = null,
63        private ?CronApiController $cronApi = null,
64        private ?MailApiController $mailApi = null,
65        private ?AuditApiController $auditApi = null,
66        ?ResponseFactoryInterface $factory = null,
67        private ?InstanceContextApiController $instanceContextApi = null,
68        private ?UserPreferencesApiController $preferencesApi = null,
69        private ?UserProfileApiController $userProfileApi = null,
70        private ?DavDeviceApiController $davDeviceApi = null,
71        private ?WebmailApiController $webmailApi = null,
72        private ?WebmailEventsApiController $webmailEventsApi = null,
73        private ?WorkflowApiController $workflowApi = null,
74        private ?MfaApiController $mfaApi = null,
75        private ?MapApiController $mapApi = null,
76        private ?PdfApiController $pdfApi = null,
77        private ?StructureApiController $structureApi = null,
78        private ?AccessApiController $accessApi = null,
79        private ?ProfilePermissionApiController $profileApi = null,
80        private ?GlobalSearchApiController $searchApi = null,
81        private ?SearchSettingsApiController $searchSettingsApi = null,
82        private ?CommentsApiController $commentsApi = null,
83        private ?DashboardApiControllerInterface $dashboardApi = null,
84        private ?ModuleBuilderApiController $moduleBuilderApi = null,
85    ) {
86        $this->factory = $factory ?? new \Nyholm\Psr7\Factory\Psr17Factory();
87    }
88
89    /**
90     * Routes system and administration requests.
91     */
92    public function route(string $path, ServerRequestInterface $req, PermissionContext $ctx): ?ResponseInterface
93    {
94        return $this->routeStructureEndpoints($path, $req)
95            ?? $this->routeAccessEndpoints($path, $req, $ctx)
96            ?? $this->routeProfileEndpoints($path, $req)
97            ?? $this->routeSearchEndpoints($path, $req)
98            ?? $this->routeCoreEndpoints($path, $req, $ctx)
99            ?? $this->routeCronAndMailEndpoints($path, $req, $ctx)
100            ?? $this->routeWorkflowEndpoints($path, $req, $ctx)
101            ?? $this->routeMapEndpoints($path, $req)
102            ?? $this->routePdfEndpoints($path, $req)
103            ?? $this->routeCommentEndpoints($path, $req, $ctx)
104            ?? $this->routeDashboardEndpoints($path, $req, $ctx)
105            ?? $this->routeModuleBuilderEndpoints($path, $req);
106    }
107
108    private function routeStructureEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
109    {
110        if ($this->structureApi === null) {
111            return null;
112        }
113
114        return match (true) {
115            $path === '/api/v1/structure/tree' && $req->getMethod() === 'GET'
116                => $this->structureApi->tree(),
117            (bool) preg_match('#^/api/v1/structure/map-points(?:/(\d+))?$#', $path, $m) && $req->getMethod() === 'GET'
118                => $this->structureApi->mapPoints(!empty($m[1]) ? (int) $m[1] : 0),
119            (bool) preg_match('#^/api/v1/structure/(\d+)/can-delete$#', $path, $m) && $req->getMethod() === 'GET'
120                => $this->structureApi->checkCanDelete((int) $m[1]),
121            (bool) preg_match('#^/api/v1/structure/(\d+)/reassign$#', $path, $m) && $req->getMethod() === 'POST'
122                => $this->structureApi->reassignAndDelete($req, (int) $m[1]),
123            (bool) preg_match('#^/api/v1/structure/(\d+)/users$#', $path, $m)
124                => match ($req->getMethod()) {
125                    'GET'   => $this->structureApi->users((int) $m[1]),
126                    'POST'  => $this->structureApi->syncUsers($req, (int) $m[1]),
127                    default => $this->factory->createResponse(405),
128                },
129            default => null,
130        };
131    }
132
133    private function routeAccessEndpoints(
134        string $path,
135        ServerRequestInterface $req,
136        PermissionContext $context
137    ): ?ResponseInterface {
138        if ($this->accessApi === null) {
139            return null;
140        }
141
142        if (str_starts_with($path, '/api/v1/access') && !$context->isSuperuser) {
143            return $this->jsonError('Superuser privileges are required for access management.', 403);
144        }
145
146        return match (true) {
147            (bool) preg_match('#^/api/v1/access/rules/([a-zA-Z0-9_.-]+)$#', $path, $m) && $req->getMethod() === 'GET'
148                => $this->accessApi->listRules($m[1]),
149            (bool) preg_match('#^/api/v1/access/rules/(\d+)$#', $path, $m) && $req->getMethod() === 'DELETE'
150                => $this->accessApi->deleteRule((int) $m[1]),
151            $path === '/api/v1/access/matrix' && $req->getMethod() === 'GET'
152                => $this->accessApi->matrix(),
153            $path === '/api/v1/access/module-level' && $req->getMethod() === 'POST'
154                => $this->accessApi->updateModuleLevel($req),
155            $path === '/api/v1/access/bulk-module-level' && $req->getMethod() === 'POST'
156                => $this->accessApi->bulkUpdateModuleLevel($req),
157            $path === '/api/v1/access/rules' && $req->getMethod() === 'POST'
158                => $this->accessApi->createRule($req),
159            (bool) preg_match('#^/api/v1/users/(\d+)/access-matrix$#', $path, $m) && $req->getMethod() === 'GET'
160                => $this->accessApi->userAccessMatrix($req, (int) $m[1]),
161            $path === '/api/v1/access/recompile' && $req->getMethod() === 'POST'
162                => $this->accessApi->recompile(),
163            default => null,
164        };
165    }
166
167    private function routeProfileEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
168    {
169        if ($this->profileApi === null) {
170            return null;
171        }
172
173        return match (true) {
174            (bool) preg_match('#^/api/v1/users/(\d+)/profile-matrix$#', $path, $m) && $req->getMethod() === 'GET'
175                => $this->profileApi->userProfileMatrix($req, (int) $m[1]),
176            (bool) preg_match('#^/api/v1/profiles/(\d+)/matrix$#', $path, $m) && $req->getMethod() === 'GET'
177                => $this->profileApi->matrix($req, (int) $m[1]),
178            (bool) preg_match('#^/api/v1/profiles/(\d+)/matrix$#', $path, $m) && $req->getMethod() === 'POST'
179                => $this->profileApi->saveMatrix($req, (int) $m[1]),
180            (bool) preg_match('#^/api/v1/profiles/(\d+)/module$#', $path, $m) && $req->getMethod() === 'POST'
181                => $this->profileApi->updateModule($req, (int) $m[1]),
182            (bool) preg_match('#^/api/v1/profiles/(\d+)/bulk-module$#', $path, $m) && $req->getMethod() === 'POST'
183                => $this->profileApi->bulkUpdateModule($req, (int) $m[1]),
184            (bool) preg_match('#^/api/v1/profiles/(\d+)/field$#', $path, $m) && $req->getMethod() === 'POST'
185                => $this->profileApi->updateField($req, (int) $m[1]),
186            (bool) preg_match('#^/api/v1/profiles/(\d+)/bulk-field$#', $path, $m) && $req->getMethod() === 'POST'
187                => $this->profileApi->bulkUpdateField($req, (int) $m[1]),
188            default => null,
189        };
190    }
191
192    private function routeSearchEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
193    {
194        $method = $req->getMethod();
195        if ($path === '/api/v1/search' && $method === 'GET') {
196            return $this->searchApi?->search($req);
197        }
198        if ($path === '/api/v1/search/settings') {
199            return match ($method) {
200                'GET'   => $this->searchSettingsApi?->getSettings($req),
201                'POST'  => $this->searchSettingsApi?->saveSettings($req),
202                default => null,
203            };
204        }
205
206        return null;
207    }
208
209    private function routeCommentEndpoints(
210        string $path,
211        ServerRequestInterface $req,
212        PermissionContext $context
213    ): ?ResponseInterface {
214        if ($this->commentsApi === null) {
215            return null;
216        }
217
218        return $this->routeCommentMentionsEndpoints($path, $req, $context)
219            ?? $this->routeCommentItemEndpoints($path, $req, $context);
220    }
221
222    private function routeCommentMentionsEndpoints(
223        string $path,
224        ServerRequestInterface $req,
225        PermissionContext $context
226    ): ?ResponseInterface {
227        if ($path === '/api/v1/comments/autocomplete' && $req->getMethod() === 'GET') {
228            return $this->commentsApi->actionAutocomplete($context);
229        }
230        if ($path === '/api/v1/comments/mentions' && $req->getMethod() === 'GET') {
231            return $this->commentsApi->actionMentions($context);
232        }
233
234        return $this->routeCommentMentionsActionEndpoints($path, $req, $context);
235    }
236
237    private function routeCommentMentionsActionEndpoints(
238        string $path,
239        ServerRequestInterface $req,
240        PermissionContext $context
241    ): ?ResponseInterface {
242        if ($path === '/api/v1/comments/mentions/unread-count' && $req->getMethod() === 'GET') {
243            return $this->commentsApi->actionMentionsUnreadCount($context);
244        }
245        if ($path === '/api/v1/comments/mentions/mark-all-read' && $req->getMethod() === 'POST') {
246            return $this->commentsApi->actionMentionsMarkAllRead($req, $context);
247        }
248
249        return (preg_match('#^/api/v1/comments/mentions/(\d+)/mark-read$#', $path, $m) && $req->getMethod() === 'POST')
250            ? $this->commentsApi->actionMentionsMarkRead($req, (int) $m[1], $context)
251            : null;
252    }
253
254    private function routeCommentItemEndpoints(
255        string $path,
256        ServerRequestInterface $req,
257        PermissionContext $context
258    ): ?ResponseInterface {
259        if (preg_match('#^/api/v1/comments/(\w+)/(\d+)$#', $path, $m)) {
260            return match ($req->getMethod()) {
261                'GET'   => $this->commentsApi->actionStream($req, $m[1], (int) $m[2], $context),
262                'POST'  => $this->commentsApi->actionCreate($req, $m[1], (int) $m[2], $context),
263                default => null,
264            };
265        }
266
267        return match (true) {
268            (bool) preg_match('#^/api/v1/comments/(\d+)/toggle-pin$#', $path, $m) && $req->getMethod() === 'POST'
269                => $this->commentsApi->actionTogglePin((int) $m[1]),
270            (bool) preg_match('#^/api/v1/comments/(\d+)/toggle-verify$#', $path, $m) && $req->getMethod() === 'POST'
271                => $this->commentsApi->actionToggleVerify((int) $m[1]),
272            (bool) preg_match('#^/api/v1/comments/(\d+)$#', $path, $m) && $req->getMethod() === 'DELETE'
273                => $this->commentsApi->actionDelete((int) $m[1], $context),
274            default => null,
275        };
276    }
277
278    private function routeCoreEndpoints(
279        string $path,
280        ServerRequestInterface $req,
281        PermissionContext $context
282    ): ?ResponseInterface {
283        if (str_starts_with($path, self::PATH_SETTINGS) && !$context->isSuperuser) {
284            return $this->jsonError('Superuser privileges are required for system settings.', 403);
285        }
286
287        return $this->routeSystemGeneralEndpoints($path, $req)
288            ?? $this->routeAuditAndContextEndpoints($path, $req)
289            ?? $this->routeUserAndDeviceEndpoints($path, $req);
290    }
291
292    private function routeSystemGeneralEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
293    {
294        return match (true) {
295            $path === '/api/v1/auth/logout'
296                => $this->authApi?->logout() ?? $this->factory->createResponse(404),
297            $path === '/api/v1/menu/tree'
298                => $this->menuApi?->tree($req) ?? $this->factory->createResponse(404),
299            $path === '/api/v1/languages'
300                => $this->transApi?->activeLanguages($req) ?? $this->factory->createResponse(404),
301            (bool) preg_match('#^/api/v1/translations/([a-zA-Z0-9_.-]+)$#', $path, $m)
302                => $this->transApi?->categoryMessages($req, $m[1]) ?? $this->factory->createResponse(404),
303            $path === self::PATH_SETTINGS
304                => $this->settingsApi?->list() ?? $this->factory->createResponse(404),
305            (bool) preg_match('#^/api/v1/settings/([a-zA-Z0-9_.-]+)$#', $path, $m)
306                => $req->getMethod() === 'PUT'
307                    ? ($this->settingsApi?->update($req, $m[1]) ?? $this->factory->createResponse(404))
308                    : ($this->settingsApi?->get($m[1]) ?? $this->factory->createResponse(404)),
309            $path === '/api/v1/about/licenses'
310                => $this->aboutApi?->index($req) ?? $this->factory->createResponse(404),
311            $path === '/api/v1/about/requirements'
312                => $this->aboutRequirementsApi?->index($req) ?? $this->factory->createResponse(404),
313            default => null,
314        };
315    }
316
317    private function routeAuditAndContextEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
318    {
319        return match (true) {
320            (bool) preg_match('#^/api/v1/layout/positions/([a-zA-Z0-9_-]+)$#', $path, $m)
321                => $this->layoutApi?->positionBlocks($req, $m[1]) ?? $this->factory->createResponse(404),
322            $path === '/api/v1/audit/integrity'
323                => $this->auditApi?->actionVerifyIntegrity($req) ?? $this->factory->createResponse(404),
324            $path === '/api/v1/audit/security-events'
325                => $this->auditApi?->actionListSecurityEvents($req) ?? $this->factory->createResponse(404),
326            $path === '/api/v1/audit/prune' && $req->getMethod() === 'POST'
327                => $this->auditApi?->actionPrune($req) ?? $this->factory->createResponse(404),
328            ($path === '/api/v1/system/context/list' || $path === '/api/v1/instance-context/list')
329                => $this->instanceContextApi?->actionList(),
330            ($path === '/api/v1/system/context/switch' || $path === '/api/v1/instance-context/switch')
331            && $req->getMethod() === 'POST'
332                => $this->instanceContextApi?->actionSwitch($req),
333            default => null,
334        };
335    }
336
337    private function routeUserAndDeviceEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
338    {
339        return match (true) {
340            $path === self::PATH_USER_PREFERENCES && $req->getMethod() === 'GET'
341                => $this->preferencesApi?->list($req),
342            $path === self::PATH_USER_PREFERENCES && $req->getMethod() === 'POST'
343                => $this->preferencesApi?->save($req),
344            $path === self::PATH_USER_PREFERENCES && $req->getMethod() === 'DELETE'
345                => $this->preferencesApi?->reset($req),
346            (bool) preg_match('#^/api/v1/user/preferences/([a-zA-Z0-9_.-]+)$#', $path, $m)
347                => $this->preferencesApi?->get($req, $m[1]),
348            $path === '/api/v1/user/profile' && $req->getMethod() === 'GET'
349                => $this->userProfileApi?->getProfile($req),
350            $path === '/api/v1/user/change-password' && $req->getMethod() === 'POST'
351                => $this->userProfileApi?->changePassword($req),
352            $path === '/api/v1/user/avatar' && $req->getMethod() === 'POST'
353                => $this->userProfileApi?->updateAvatar($req),
354            $path === '/api/v1/dav/devices' && $req->getMethod() === 'GET'
355                => $this->davDeviceApi?->listDevices($req),
356            $path === '/api/v1/dav/devices' && $req->getMethod() === 'POST'
357                => $this->davDeviceApi?->createDevice($req),
358            (bool) preg_match('#^/api/v1/dav/devices/(\d+)$#', $path, $m) && $req->getMethod() === 'DELETE'
359                => $this->davDeviceApi?->deleteDevice($req, (int) $m[1]),
360            $path === '/api/v1/user/mfa/devices' && $req->getMethod() === 'GET'
361                => $this->mfaApi?->listDevices($req),
362            $path === '/api/v1/user/mfa/enroll' && $req->getMethod() === 'POST'
363                => $this->mfaApi?->enrollDevice($req),
364            $path === '/api/v1/user/mfa/confirm' && $req->getMethod() === 'POST'
365                => $this->mfaApi?->confirmDevice($req),
366            $path === '/api/v1/user/mfa/revoke' && $req->getMethod() === 'POST'
367                => $this->mfaApi?->revokeDevice($req),
368            $path === '/api/v1/user/mfa/default' && $req->getMethod() === 'POST'
369                => $this->mfaApi?->setDefaultDevice($req),
370            default => null,
371        };
372    }
373
374    private function routeCronAndMailEndpoints(
375        string $path,
376        ServerRequestInterface $req,
377        PermissionContext $context
378    ): ?ResponseInterface {
379        if (str_starts_with($path, '/api/v1/cron') && !$context->isSuperuser) {
380            return $this->jsonError('Superuser privileges are required for cron tasks.', 403);
381        }
382
383        return $this->routeWebmailEndpoints($path, $req)
384            ?? $this->routeMailServerEndpoints($path, $req)
385            ?? $this->routeCronTaskEndpoints($path, $req);
386    }
387
388    private function routeWebmailEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
389    {
390        return match (true) {
391            $path === '/api/v1/mail/events' && $req->getMethod() === 'GET'
392                => $this->webmailEventsApi?->stream(),
393            $path === '/api/v1/mail/webmail/check-new' && in_array($req->getMethod(), ['GET', 'POST'], true)
394                => $this->webmailApi?->checkNew($req),
395            $path === '/api/v1/mail/webmail/accounts' && $req->getMethod() === 'GET'
396                => $this->webmailApi?->accounts(),
397            $path === '/api/v1/mail/webmail/connect' && $req->getMethod() === 'POST'
398                => $this->webmailApi?->connect($req),
399            (bool) preg_match('#^/api/v1/mail/webmail/mailboxes/(\d+)/folders$#', $path, $m)
400                && $req->getMethod() === 'GET'
401                => $this->webmailApi?->folders((int) $m[1]),
402            (bool) preg_match('#^/api/v1/mail/webmail/mailboxes/(\d+)/messages/([^/]+)/parts/([^/]+)$#', $path, $m)
403                && $req->getMethod() === 'GET'
404                => $this->webmailApi?->downloadAttachment($req, (int) $m[1], $m[2], $m[3]),
405            (bool) preg_match('#^/api/v1/mail/webmail/mailboxes/(\d+)/messages/([^/]+)$#', $path, $m)
406                && $req->getMethod() === 'GET'
407                => $this->webmailApi?->messageDetail($req, (int) $m[1], $m[2]),
408            (bool) preg_match('#^/api/v1/mail/webmail/mailboxes/(\d+)/messages$#', $path, $m)
409                && $req->getMethod() === 'GET'
410                => $this->webmailApi?->messages($req, (int) $m[1]),
411            (bool) preg_match('#^/api/v1/mail/webmail/mailboxes/(\d+)/send$#', $path, $m)
412                && $req->getMethod() === 'POST'
413                => $this->webmailApi?->send($req, (int) $m[1]),
414            (bool) preg_match('#^/api/v1/mail/webmail/mailboxes/(\d+)/reauth$#', $path, $m)
415                && $req->getMethod() === 'POST'
416                => $this->webmailApi?->reauth($req, (int) $m[1]),
417            default => null,
418        };
419    }
420
421    private function routeMailServerEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
422    {
423        return match (true) {
424            (bool) preg_match('#^/api/v1/mail/smtp/(\d+)/test-connection$#', $path, $m)
425                && $req->getMethod() === 'POST'
426                => $this->mailApi?->testSmtpConnection($req, (int) $m[1]) ?? $this->factory->createResponse(404),
427            (bool) preg_match('#^/api/v1/mail/servers/(\d+)/test-connection$#', $path, $m)
428                && $req->getMethod() === 'POST'
429                => $this->mailApi?->testServerConnection((int) $m[1]) ?? $this->factory->createResponse(404),
430            (bool) preg_match('#^/api/v1/mail/mailboxes/(\d+)/test-connection$#', $path, $m)
431                && $req->getMethod() === 'POST'
432                => $this->mailApi?->testMailboxConnection((int) $m[1]) ?? $this->factory->createResponse(404),
433            $path === '/api/v1/mail/enqueue' && $req->getMethod() === 'POST'
434                => $this->mailApi?->enqueue($req) ?? $this->factory->createResponse(404),
435            default => null,
436        };
437    }
438
439    private function routeCronTaskEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
440    {
441        return match (true) {
442            $path === '/api/v1/cron/tasks'
443                => $this->cronApi?->tasks() ?? $this->factory->createResponse(404),
444            (bool) preg_match('#^/api/v1/cron/lock/(\d+)$#', $path, $m)
445                => $this->cronApi?->lock((int) $m[1]) ?? $this->factory->createResponse(404),
446            (bool) preg_match('#^/api/v1/cron/unlock/(\d+)$#', $path, $m)
447                => $this->cronApi?->unlock($req, (int) $m[1]) ?? $this->factory->createResponse(404),
448            (bool) preg_match('#^/api/v1/cron/unlock-timeout/(\d+)$#', $path, $m)
449                => $this->cronApi?->unlockTimeout($req, (int) $m[1]) ?? $this->factory->createResponse(404),
450            $path === '/api/v1/cron/log'
451                => $this->cronApi?->log($req) ?? $this->factory->createResponse(404),
452            $path === '/api/v1/cron/tasks/cleanup-sessions'
453                => $this->cronApi?->cleanupSessions($req) ?? $this->factory->createResponse(404),
454            $path === '/api/v1/cron/tasks/cleanup-logs'
455                => $this->cronApi?->cleanupLogs($req) ?? $this->factory->createResponse(404),
456            default => null,
457        };
458    }
459
460    private function routeWorkflowEndpoints(
461        string $path,
462        ServerRequestInterface $req,
463        PermissionContext $ctx
464    ): ?ResponseInterface {
465        if ($this->workflowApi === null) {
466            return null;
467        }
468
469        if (preg_match('#^/api/v1/workflows/(\d+)/graph$#', $path, $m)) {
470            $id = (int) $m[1];
471            return match ($req->getMethod()) {
472                'GET'   => $this->workflowApi->actionGetGraph($id),
473                'PUT'   => $this->workflowApi->actionSaveGraph($req, $id),
474                default => $this->factory->createResponse(405),
475            };
476        }
477
478        $isManual = preg_match('#^/api/v1/workflows/(\d+)/execute-manual$#', $path, $m)
479            && $req->getMethod() === 'POST';
480
481        return $isManual ? $this->workflowApi->actionExecuteManual($req, (int) $m[1], $ctx) : null;
482    }
483
484    private function routeMapEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
485    {
486        if ($this->mapApi === null) {
487            return null;
488        }
489
490        return match (true) {
491            $path === '/api/v1/maps/geocode' && $req->getMethod() === 'GET'
492                => $this->mapApi->actionGeocode($req),
493            $path === '/api/v1/maps/reverse' && $req->getMethod() === 'GET'
494                => $this->mapApi->actionReverse($req),
495            $path === '/api/v1/maps/route' && $req->getMethod() === 'GET'
496                => $this->mapApi->actionRoute($req),
497            $path === '/api/v1/maps/points' && $req->getMethod() === 'GET'
498                => $this->mapApi->actionPoints($req),
499            $path === '/api/v1/maps/hierarchy' && $req->getMethod() === 'GET'
500                => $this->mapApi->actionHierarchy($req),
501            $path === '/api/v1/maps/trip' && $req->getMethod() === 'GET'
502                => $this->mapApi->actionTrip($req),
503            $path === '/api/v1/maps/nearby' && $req->getMethod() === 'GET'
504                => $this->mapApi->actionNearby($req),
505            $path === '/api/v1/maps/gpx' && $req->getMethod() === 'GET'
506                => $this->mapApi->actionGpx($req),
507            $path === '/api/v1/maps/providers' && $req->getMethod() === 'GET'
508                => $this->mapApi->actionProviders(),
509            default => null,
510        };
511    }
512
513    private function routePdfEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
514    {
515        if ($this->pdfApi === null) {
516            return null;
517        }
518
519        if (preg_match('#^/api/v1/pdf/generate/(\d+)/(\d+)$#', $path, $m) && $req->getMethod() === 'GET') {
520            return $this->pdfApi->generate($req, (int) $m[1], (int) $m[2]);
521        }
522
523        return match (true) {
524            (bool) preg_match('#^/api/v1/pdf/templates/(\d+)/versions$#', $path, $m) && $req->getMethod() === 'GET'
525                => $this->pdfApi->listVersions((int) $m[1]),
526            (bool) preg_match('#^/api/v1/pdf/templates/(\d+)/versions/(\d+)/rollback$#', $path, $m)
527                && $req->getMethod() === 'POST'
528                => $this->pdfApi->rollbackVersion((int) $m[1], (int) $m[2]),
529            $path === '/api/v1/pdf/templates' && $req->getMethod() === 'GET'
530                => $this->pdfApi->listTemplates($req),
531            $path === '/api/v1/pdf/variables' && $req->getMethod() === 'GET'
532                => $this->pdfApi->listVariables($req),
533            $path === '/api/v1/pdf/compile-blocks' && $req->getMethod() === 'POST'
534                => $this->pdfApi->compileBlocks($req),
535            default => null,
536        };
537    }
538
539    private function routeDashboardEndpoints(
540        string $path,
541        ServerRequestInterface $req,
542        PermissionContext $ctx
543    ): ?ResponseInterface {
544        if ($this->dashboardApi === null) {
545            return null;
546        }
547
548        return $this->routeDashboardWidgetEndpoints($path, $req, $ctx)
549            ?? $this->routeDashboardEventEndpoints($path, $req, $ctx);
550    }
551
552    private function routeDashboardWidgetEndpoints(
553        string $path,
554        ServerRequestInterface $req,
555        PermissionContext $ctx
556    ): ?ResponseInterface {
557        if ($path === '/api/v1/dashboard/widgets') {
558            return match ($req->getMethod()) {
559                'GET'   => $this->dashboardApi->actionWidgets($req, $ctx),
560                default => null,
561            };
562        }
563        if ($path === '/api/v1/dashboard/widgets/toggle' && $req->getMethod() === 'POST') {
564            return $this->dashboardApi->actionToggle($req, $ctx);
565        }
566
567        return $this->routeDashboardEventsWidgets($path, $req, $ctx);
568    }
569
570    private function routeDashboardEventsWidgets(
571        string $path,
572        ServerRequestInterface $req,
573        PermissionContext $ctx
574    ): ?ResponseInterface {
575        if ($path === '/api/v1/dashboard/widgets/current-events' && $req->getMethod() === 'GET') {
576            return $this->dashboardApi->actionCurrentEvents($req, $ctx);
577        }
578        if ($path === '/api/v1/dashboard/widgets/overdue-events' && $req->getMethod() === 'GET') {
579            return $this->dashboardApi->actionOverdueEvents($req, $ctx);
580        }
581
582        return null;
583    }
584
585    private function routeDashboardEventEndpoints(
586        string $path,
587        ServerRequestInterface $req,
588        PermissionContext $ctx
589    ): ?ResponseInterface {
590        if (preg_match('#^/api/v1/dashboard/events/(\d+)$#', $path, $m) && $req->getMethod() === 'GET') {
591            return $this->dashboardApi->actionEventDetails($req, (int) $m[1], $ctx);
592        }
593        if (preg_match('#^/api/v1/dashboard/events/(\d+)/action$#', $path, $m) && $req->getMethod() === 'POST') {
594            return $this->dashboardApi->actionEventAction($req, (int) $m[1], $ctx);
595        }
596
597        return null;
598    }
599
600    private function jsonError(string $message, int $status): ResponseInterface
601    {
602        $response = $this->factory->createResponse($status);
603        $payload = json_encode([
604            'success' => false,
605            'error'   => $message,
606        ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
607        $response->getBody()->write($payload);
608
609        return $response->withHeader('Content-Type', 'application/json');
610    }
611
612    private function routeModuleBuilderEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
613    {
614        if ($this->moduleBuilderApi === null) {
615            return null;
616        }
617
618        return match (true) {
619            $path === '/api/v1/system/module-builder/metadata-options' && $req->getMethod() === 'GET'
620                => $this->moduleBuilderApi->metadataOptions($req),
621            (bool) preg_match('#^/api/v1/system/module-builder/schema/(\d+)$#', $path, $m)
622                && $req->getMethod() === 'GET'
623                => $this->moduleBuilderApi->schema((int) $m[1], $req),
624            $path === '/api/v1/system/module-builder/suggest-field' && $req->getMethod() === 'POST'
625                => $this->moduleBuilderApi->suggestField($req),
626            $path === '/api/v1/system/module-builder/create' && $req->getMethod() === 'POST'
627                => $this->moduleBuilderApi->create($req),
628            (bool) preg_match('#^/api/v1/system/module-builder/update/(\d+)$#', $path, $m)
629                && in_array($req->getMethod(), ['PUT', 'PATCH', 'POST'], true)
630                => $this->moduleBuilderApi->update((int) $m[1], $req),
631            (bool) preg_match('#^/api/v1/system/module-builder/(\d+)$#', $path, $m)
632                && $req->getMethod() === 'DELETE'
633                => $this->moduleBuilderApi->delete((int) $m[1], $req),
634            default => null,
635        };
636    }
637}