Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.16% covered (warning)
86.16%
249 / 289
50.00% covered (danger)
50.00%
11 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
BootstrapRouteDispatcher
86.11% covered (warning)
86.11%
248 / 288
50.00% covered (danger)
50.00%
11 / 22
150.43
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
 getDomainDispatchers
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
1 / 1
1
 getUrlMatcher
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDashboardApiController
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 dispatch
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 dispatchDynamicRoutes
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 dispatchDataExchange
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
90
 dispatchFastAndStatic
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 dispatchFastRouteMatch
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
8.81
 dispatchStaticRoutes
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
20.42
 dispatchHtmxEngineRoutes
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 dispatchApiV1Routes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dispatchWebRoutes
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 tryDispatchModuleBuilderRoutes
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
8.05
 tryDispatchDomain
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 resolveDomainFallbackPath
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 findDomainResponse
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 resolveAliasedPath
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
15.48
 dispatchCentralEngineWebRoute
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
7
 matchModuleRoute
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 matchSingleModuleRoute
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
13
 resolveRemoteRedirect
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
7.07
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\Bootstrap\Configurator;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Access\Presentation\Web\AccessWebController;
12use App\Core\Api\Presentation\ApiDispatcher;
13use App\Core\Api\Presentation\Web\OpenApiDocController;
14use App\Core\Bootstrap\Dispatcher\CommerceRouteDispatcher;
15use App\Core\Bootstrap\Dispatcher\DashboardAndAnalyticsRouteDispatcher;
16use App\Core\Bootstrap\Dispatcher\DocumentAndPdfRouteDispatcher;
17use App\Core\Bootstrap\Dispatcher\ModuleRouteDispatcherInterface;
18use App\Core\Bootstrap\Dispatcher\SpecialPlatformRouteDispatcher;
19use App\Core\Bootstrap\Dispatcher\WebmailRouteDispatcher;
20use App\Core\DataExchange\Presentation\Htmx\DataExchangeHtmxController;
21use App\Core\Engine\Application\Security\PermissionContextFactory;
22use App\Core\Engine\Domain\Model\ModuleMetadata;
23use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
24use App\Core\Engine\Presentation\Api\CentralEngineApiController;
25use App\Core\Engine\Presentation\Api\MediaUploadApiControllerInterface;
26use App\Core\Engine\Presentation\Api\ModuleFieldsApiController;
27use App\Core\Engine\Presentation\Api\ModuleRelationsApiController;
28use App\Core\Engine\Presentation\Api\PicklistValuesApiController;
29use App\Core\Engine\Presentation\Api\RelationMmApiController;
30use App\Core\Engine\Presentation\Web\CentralEngineWebController;
31use App\Core\Engine\Presentation\Web\HtmxEngineRequestHandler;
32use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
33use App\Core\ModuleBuilder\Presentation\Web\BuildersHubWebController;
34use App\Core\ModuleBuilder\Presentation\Web\ModuleBuilderWebController;
35use App\Core\Router\AppRouterFactory;
36use App\Core\Security\Middleware\CsrfMiddleware;
37use App\Core\Translation\Presentation\Api\LanguageTranslationsApiController;
38use App\Modules\Calendar\Presentation\Web\CalendarRsvpWebController;
39use App\Modules\Comments\Presentation\Htmx\CommentsHtmxController;
40use App\Modules\Comments\Presentation\Htmx\MentionsHtmxController;
41use App\Modules\Comments\Presentation\Web\CommentAttachmentDownloadWebController;
42use App\Modules\Comments\Presentation\Web\MentionsWebController;
43use App\Modules\Currencies\Presentation\Web\CurrenciesWebController;
44use App\Modules\Dashboard\Presentation\Api\DashboardApiController;
45use App\Modules\Dashboard\Presentation\Htmx\DashboardHtmxController;
46use App\Modules\Dashboard\Presentation\Web\DashboardWebController;
47use App\Modules\DataMapping\Presentation\Htmx\DataMappingHtmxController;
48use App\Modules\DataMapping\Presentation\Web\DataMappingWebController;
49use App\Modules\Dav\Presentation\Web\DavSetupWebController;
50use App\Modules\Discount\Presentation\Htmx\DiscountHtmxController;
51use App\Modules\Discount\Presentation\Web\DiscountSettingsWebController;
52use App\Modules\Documents\Presentation\Web\DocumentDownloadWebController;
53use App\Modules\Pdf\Presentation\Web\DocumentVerificationWebController;
54use App\Modules\Inventory\Presentation\Htmx\InventoryItemsHtmxController;
55use App\Modules\Inventory\Presentation\Htmx\InventorySettingsHtmxController;
56use App\Modules\Inventory\Presentation\Web\InventorySettingsWebController;
57use App\Modules\Logs\Presentation\Api\JsErrorLogApiController;
58use App\Modules\Mail\Presentation\Htmx\WebmailHtmxController;
59use App\Modules\Mail\Presentation\Web\MailTemplateWebController;
60use App\Modules\Mail\Presentation\Web\MailTrackingWebController;
61use App\Modules\Mail\Presentation\Web\WebmailWebController;
62use App\Modules\Map\Presentation\Htmx\MapHtmxController;
63use App\Modules\Map\Presentation\Web\MapWebController;
64use App\Modules\Pdf\Presentation\Htmx\PdfHtmxController;
65use App\Modules\Pdf\Presentation\Htmx\PdfQueueHtmxController;
66use App\Modules\Pdf\Presentation\Htmx\PdfSignatureHtmxController;
67use App\Modules\Pdf\Presentation\Web\PdfWebController;
68use App\Modules\Quotes\Presentation\Web\QuoteConversionWebController;
69use App\Modules\Tax\Presentation\Htmx\TaxHtmxController;
70use App\Modules\Tax\Presentation\Web\TaxSettingsWebController;
71use App\Modules\User\Infrastructure\Repository\SqlUserRepository;
72use App\Modules\User\Presentation\Middleware\AuthenticatedOnlyMiddleware;
73use App\Modules\User\Presentation\Middleware\GuestOnlyMiddleware;
74use App\Modules\User\Presentation\Web\AuthController;
75use App\Modules\User\Presentation\Web\ImpersonationWebController;
76use Nyholm\Psr7\Factory\Psr17Factory;
77use Psr\Http\Message\ResponseInterface;
78use Psr\Http\Message\ServerRequestInterface;
79use Psr\Http\Server\RequestHandlerInterface;
80use Twig\Environment as TwigEnvironment;
81use Yiisoft\Router\UrlMatcherInterface;
82use Yiisoft\Session\SessionInterface;
83use Yiisoft\User\CurrentUser;
84
85/**
86 * BootstrapRouteDispatcher.
87 *
88 * Lightweight orchestrator delegating requests to FastRoute, domain sub-dispatchers, API or CentralEngine.
89 *
90 * @package App\Core\Bootstrap\Configurator
91 */
92final readonly class BootstrapRouteDispatcher
93{
94    private const PATH_DASHBOARD = '/dashboard';
95
96    /**
97     * BootstrapRouteDispatcher constructor.
98     */
99    public function __construct(
100        private OpenApiDocController $openApiDocController,
101        private JsErrorLogApiController $jsErrorLogApiController,
102        private ApiDispatcher $apiDispatcher,
103        private AuthController $authController,
104        private CentralEngineWebController $centralEngineWebController,
105        private CentralEngineApiController $centralEngineApiController,
106        private PicklistValuesApiController $picklistValuesApiController,
107        private ModuleFieldsApiController $moduleFieldsApiController,
108        private ModuleRelationsApiController $moduleRelationsApiController,
109        private MetadataRepositoryInterface $metadataRepo,
110        private SqlUserRepository $userRepository,
111        private CurrentUser $currentUser,
112        private SessionInterface $session,
113        private PermissionContextFactory $contextFactory,
114        private AuthenticatedOnlyMiddleware $authMiddleware,
115        private GuestOnlyMiddleware $guestMiddleware,
116        private CsrfMiddleware $csrfMiddleware,
117        private Psr17Factory $psr17Factory,
118        private ?InstanceContextManagerInterface $instanceContextManager = null,
119        private ?RelationMmApiController $relationMmApiController = null,
120        private ?DavSetupWebController $davSetupWebController = null,
121        private ?WebmailWebController $webmailWebController = null,
122        private ?WebmailHtmxController $webmailHtmxController = null,
123        private ?TwigEnvironment $twig = null,
124        private ?MediaUploadApiControllerInterface $mediaUploadApiController = null,
125        private ?DocumentDownloadWebController $documentDownloadWebController = null,
126        private ?MapWebController $mapWebController = null,
127        private ?MapHtmxController $mapHtmxController = null,
128        private ?PdfHtmxController $pdfHtmxController = null,
129        private ?PdfWebController $pdfWebController = null,
130        private ?DocumentVerificationWebController $documentVerificationWebController = null,
131        private ?PdfSignatureHtmxController $pdfSignatureHtmxController = null,
132        private ?PdfQueueHtmxController $pdfQueueHtmxController = null,
133        private ?MailTrackingWebController $mailTrackingWebController = null,
134        private ?MailTemplateWebController $mailTemplateWebController = null,
135        private ?AccessWebController $accessWebController = null,
136        private ?CommentsHtmxController $commentsHtmxController = null,
137        private ?CommentAttachmentDownloadWebController $commentAttachmentDownloadWebController = null,
138        private ?MentionsWebController $mentionsWebController = null,
139        private ?MentionsHtmxController $mentionsHtmxController = null,
140        private ?ImpersonationWebController $impersonationWebController = null,
141        private ?DashboardWebController $dashboardWebController = null,
142        private ?DashboardApiController $dashboardApiController = null,
143        private ?DashboardHtmxController $dashboardHtmxController = null,
144        private ?string $appProfile = 'admin',
145        private ?UrlMatcherInterface $urlMatcher = null,
146        private ?TaxSettingsWebController $taxSettingsWebController = null,
147        private ?TaxHtmxController $taxHtmxController = null,
148        private ?DiscountSettingsWebController $discountSettingsWebController = null,
149        private ?DiscountHtmxController $discountHtmxController = null,
150        private ?InventorySettingsWebController $inventorySettingsWebController = null,
151        private ?InventorySettingsHtmxController $inventorySettingsHtmxController = null,
152        private ?InventoryItemsHtmxController $inventoryItemsHtmxController = null,
153        private ?DataMappingWebController $dataMappingWebController = null,
154        private ?DataMappingHtmxController $dataMappingHtmxController = null,
155        private ?QuoteConversionWebController $quoteConversionWebController = null,
156        private ?CurrenciesWebController $currenciesWebController = null,
157        private ?LanguageTranslationsApiController $languageTranslationsApiController = null,
158        private ?DataExchangeHtmxController $dataExchangeHtmxController = null,
159        private ?ModuleBuilderWebController $moduleBuilderWebController = null,
160        private ?CalendarRsvpWebController $calendarRsvpWebController = null,
161        private ?BuildersHubWebController $buildersHubWebController = null
162    ) {
163    }
164
165    /**
166     * Returns list of domain sub-dispatchers.
167     *
168     * @return list<ModuleRouteDispatcherInterface>
169     */
170    private function getDomainDispatchers(): array
171    {
172        return [
173            new CommerceRouteDispatcher(
174                $this->taxSettingsWebController,
175                $this->taxHtmxController,
176                $this->discountSettingsWebController,
177                $this->discountHtmxController,
178                $this->inventorySettingsWebController,
179                $this->inventorySettingsHtmxController,
180                $this->inventoryItemsHtmxController,
181                $this->dataMappingWebController,
182                $this->dataMappingHtmxController,
183                $this->quoteConversionWebController,
184                $this->authMiddleware,
185                $this->psr17Factory,
186                $this->currenciesWebController
187            ),
188            new DocumentAndPdfRouteDispatcher(
189                $this->pdfHtmxController,
190                $this->pdfSignatureHtmxController,
191                $this->pdfQueueHtmxController,
192                $this->pdfWebController,
193                $this->documentDownloadWebController,
194                $this->commentAttachmentDownloadWebController,
195                $this->contextFactory,
196                $this->authMiddleware,
197                $this->psr17Factory
198            ),
199            new WebmailRouteDispatcher(
200                $this->webmailWebController,
201                $this->mailTemplateWebController,
202                $this->webmailHtmxController,
203                $this->authMiddleware,
204                $this->csrfMiddleware,
205                $this->psr17Factory
206            ),
207            new SpecialPlatformRouteDispatcher(
208                $this->mentionsWebController,
209                $this->accessWebController,
210                $this->impersonationWebController,
211                $this->authController,
212                $this->davSetupWebController,
213                $this->userRepository,
214                $this->currentUser,
215                $this->session,
216                $this->contextFactory,
217                $this->authMiddleware,
218                $this->guestMiddleware,
219                $this->csrfMiddleware,
220                $this->psr17Factory,
221                $this->appProfile ?? 'admin'
222            ),
223            new DashboardAndAnalyticsRouteDispatcher(
224                $this->dashboardWebController,
225                $this->dashboardHtmxController,
226                $this->mapWebController,
227                $this->mapHtmxController,
228                $this->commentsHtmxController,
229                $this->mentionsHtmxController,
230                $this->contextFactory,
231                $this->authMiddleware,
232                $this->csrfMiddleware,
233                $this->psr17Factory
234            ),
235        ];
236    }
237
238    public function getUrlMatcher(): UrlMatcherInterface
239    {
240        return $this->urlMatcher ?? AppRouterFactory::createMatcher();
241    }
242
243    public function getDashboardApiController(): ?DashboardApiController
244    {
245        return $this->dashboardApiController;
246    }
247
248    public function dispatch(ServerRequestInterface $request): ResponseInterface
249    {
250        $path = parse_url($request->getUri()->getPath(), PHP_URL_PATH) ?? '/';
251
252        $earlyResponse = $this->dispatchFastAndStatic($path, $request);
253        if ($earlyResponse !== null) {
254            return $earlyResponse;
255        }
256
257        return $this->dispatchDynamicRoutes($path, $request);
258    }
259
260    private function dispatchDynamicRoutes(string $path, ServerRequestInterface $request): ResponseInterface
261    {
262        $dataExchangePattern = '#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/' .
263            '(export-modal|export-download|import-modal|import-upload|import-start|import-progress(?:/\d+)?)$#';
264        if ((bool) preg_match($dataExchangePattern, $path)) {
265            return $this->dispatchDataExchange($path, $request);
266        }
267
268        if (str_starts_with($path, '/htmx/engine/')) {
269            return $this->dispatchHtmxEngineRoutes($path, $request);
270        }
271
272        return str_starts_with($path, '/api/v1/')
273            ? $this->dispatchApiV1Routes($path, $request)
274            : $this->dispatchWebRoutes($path, $request);
275    }
276
277    private function dispatchDataExchange(string $path, ServerRequestInterface $request): ResponseInterface
278    {
279        if ($this->dataExchangeHtmxController === null) {
280            return $this->psr17Factory->createResponse(404);
281        }
282
283        $ctx = $this->contextFactory->createFromRequest($request);
284
285        return match (true) {
286            (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/export-modal$#', $path, $m)
287                => $this->dataExchangeHtmxController->actionExportModal($request, $m[1]),
288            (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/export-download$#', $path, $m)
289                => $this->dataExchangeHtmxController->actionExportDownload($request, $m[1], $ctx),
290            (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/import-modal$#', $path, $m)
291                => $this->dataExchangeHtmxController->actionImportModal($m[1]),
292            (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/import-upload$#', $path, $m)
293                => $this->dataExchangeHtmxController->actionUploadAndAnalyze($request, $m[1]),
294            (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/import-start$#', $path, $m)
295                => $this->dataExchangeHtmxController->actionStartImport($request, $m[1], $ctx),
296            (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/import-progress/(\d+)$#', $path, $m)
297                => $this->dataExchangeHtmxController->actionImportProgress($m[1], (int) $m[2]),
298            default => $this->psr17Factory->createResponse(404),
299        };
300    }
301
302    private function dispatchFastAndStatic(string $path, ServerRequestInterface $request): ?ResponseInterface
303    {
304        $matchResult = $this->getUrlMatcher()->match($request);
305        if ($matchResult->isSuccess()) {
306            $routeName = (string) $matchResult->route()->getData('name');
307            $matched = $this->dispatchFastRouteMatch($routeName, $request);
308            if ($matched !== null) {
309                return $matched;
310            }
311        }
312
313        $response = $this->dispatchStaticRoutes($path, $request);
314        if ($response !== null) {
315            return $response;
316        }
317
318        return $this->findDomainResponse($path, $request);
319    }
320
321    private function dispatchFastRouteMatch(
322        string $routeName,
323        ServerRequestInterface $request
324    ): ?ResponseInterface {
325        return match ($routeName) {
326            'api_doc_ui', 'api_doc_ui_slash' => $this->openApiDocController->ui(),
327            'openapi_spec'                   => $this->openApiDocController->jsonSpec(),
328            'js_error_log'                   => $request->getMethod() === 'POST'
329                ? $this->jsErrorLogApiController->log($request)
330                : null,
331            'locale_translations'            => $this->apiDispatcher->dispatch(
332                '/api/v1/locale/translations',
333                $request
334            ),
335            default                          => null,
336        };
337    }
338
339    private function dispatchStaticRoutes(string $path, ServerRequestInterface $request): ?ResponseInterface
340    {
341        return match (true) {
342            (bool) preg_match('#^/verify/([a-zA-Z0-9]+)$#', $path, $m) && $request->getMethod() === 'GET'
343                => $this->documentVerificationWebController?->verify($m[1]),
344            (bool) preg_match('#^/mail/track/open/([a-zA-Z0-9]+)$#', $path, $m) && $request->getMethod() === 'GET'
345                => $this->mailTrackingWebController?->trackOpen($request, $m[1]),
346            (bool) preg_match('#^/mail/track/click/([a-zA-Z0-9]+)$#', $path, $m) && $request->getMethod() === 'GET'
347                => $this->mailTrackingWebController?->trackClick($request, $m[1]),
348            (bool) preg_match('#^/pdf/queue/download/(\d+)$#', $path, $m) && $request->getMethod() === 'GET'
349                => $this->pdfQueueHtmxController?->download((int) $m[1]),
350            $path === '/calendar/rsvp/download' && $request->getMethod() === 'GET'
351                => $this->calendarRsvpWebController?->downloadIcs($request),
352            $path === '/calendar/rsvp' && $request->getMethod() === 'GET'
353                => $this->calendarRsvpWebController?->handle($request),
354            $path === '/api' || $path === '/api/' => $this->openApiDocController->ui(),
355            $path === '/api/v1/openapi.json'      => $this->openApiDocController->jsonSpec(),
356            $path === '/api/v1/logs/js-error' && $request->getMethod() === 'POST'
357                => $this->jsErrorLogApiController->log($request),
358            default                               => null,
359        };
360    }
361
362    private function dispatchHtmxEngineRoutes(string $path, ServerRequestInterface $request): ResponseInterface
363    {
364        $handler = new HtmxEngineRequestHandler(
365            $this->authMiddleware,
366            $path,
367            $this->centralEngineApiController,
368            $this->picklistValuesApiController,
369            $this->moduleFieldsApiController,
370            $this->moduleRelationsApiController,
371            $this->contextFactory,
372            $this->psr17Factory,
373            $this->relationMmApiController,
374            $this->twig,
375            $this->mediaUploadApiController,
376            $this->languageTranslationsApiController,
377            $this->dataExchangeHtmxController,
378        );
379
380        return $this->csrfMiddleware->process($request, $handler);
381    }
382
383    private function dispatchApiV1Routes(string $path, ServerRequestInterface $request): ResponseInterface
384    {
385        return $this->apiDispatcher->dispatch($path, $request);
386    }
387
388    private function dispatchWebRoutes(string $path, ServerRequestInterface $request): ResponseInterface
389    {
390        $domainResponse = $this->tryDispatchDomain($path, $request);
391        if ($domainResponse !== null) {
392            return $domainResponse;
393        }
394
395        $mbResponse = $this->tryDispatchModuleBuilderRoutes($path, $request);
396        if ($mbResponse !== null) {
397            return $mbResponse;
398        }
399
400        $effectivePath = ($path === '/' || $path === '') ? self::PATH_DASHBOARD : $path;
401
402        return $this->dispatchCentralEngineWebRoute($effectivePath, $request);
403    }
404
405    private function tryDispatchModuleBuilderRoutes(string $path, ServerRequestInterface $request): ?ResponseInterface
406    {
407        if ($request->getMethod() !== 'GET') {
408            return null;
409        }
410
411        $ctx = $this->contextFactory->createFromRequest($request);
412        return match (true) {
413            $path === '/system/builders' => $this->buildersHubWebController?->actionIndex()
414                ?? $this->psr17Factory->createResponse(404),
415            $path === '/system/modules/builder/new' => $this->moduleBuilderWebController?->actionNew($ctx)
416                ?? $this->psr17Factory->createResponse(404),
417            (bool) preg_match('#^/system/modules/(\d+)/builder$#', $path, $m)
418                => $this->moduleBuilderWebController?->actionEdit((int) $m[1], $ctx)
419                    ?? $this->psr17Factory->createResponse(404),
420            default => null,
421        };
422    }
423
424    private function tryDispatchDomain(string $path, ServerRequestInterface $request): ?ResponseInterface
425    {
426        $response = $this->findDomainResponse($path, $request);
427        if ($response !== null) {
428            return $response;
429        }
430
431        $fallbackPath = $this->resolveDomainFallbackPath($path);
432        return $fallbackPath !== null ? $this->findDomainResponse($fallbackPath, $request) : null;
433    }
434
435    private function resolveDomainFallbackPath(string $path): ?string
436    {
437        $aliasedPath = $this->resolveAliasedPath($path);
438        if ($aliasedPath !== null) {
439            return $aliasedPath;
440        }
441
442        return (($path === '/' || $path === '') && $this->dashboardWebController !== null)
443            ? self::PATH_DASHBOARD
444            : null;
445    }
446
447    private function findDomainResponse(string $path, ServerRequestInterface $request): ?ResponseInterface
448    {
449        foreach ($this->getDomainDispatchers() as $dispatcher) {
450            if ($dispatcher->canDispatch($path)) {
451                $subResponse = $dispatcher->dispatch($path, $request);
452                if ($subResponse !== null) {
453                    return $subResponse;
454                }
455            }
456        }
457
458        return null;
459    }
460
461    private function resolveAliasedPath(string $path): ?string
462    {
463        return match ($path) {
464            '/settings/taxes', '/settings/taxes/' => '/settings/tax',
465            '/settings/discounts', '/settings/discounts/' => '/settings/discount',
466            '/system/access', '/system/access/' => '/settings/access',
467            '/system/access/admin', '/system/access/admin/' => '/settings/access/admin',
468            '/system/access/client', '/system/access/client/' => '/settings/access/client',
469            '/system/access/rules-drawer', '/system/access/rules/drawer' => '/settings/access/rules-drawer',
470            '/system/impersonation', '/system/impersonation/' => '/impersonation/panel',
471            '/system/impersonation/search-users' => '/impersonation/search',
472            '/system/impersonation/save' => '/impersonation/save',
473            default => null,
474        };
475    }
476
477    private function dispatchCentralEngineWebRoute(
478        string $path,
479        ServerRequestInterface $request
480    ): ResponseInterface {
481        $activeModules = $this->metadataRepo->findAllActiveModules();
482        $trimPath = '/' . trim($path, '/');
483
484        [$matchedModule, $action, $recordId] = $this->matchModuleRoute($trimPath, $activeModules);
485        if ($matchedModule === null) {
486            return $this->psr17Factory->createResponse(404);
487        }
488
489        $redirectResponse = $this->resolveRemoteRedirect($matchedModule, $activeModules);
490        if ($redirectResponse !== null) {
491            return $redirectResponse;
492        }
493
494        $moduleName = $matchedModule->name;
495
496        return $this->authMiddleware->process(
497            $request,
498            new class($this->centralEngineWebController, $moduleName, $action, $recordId, $this->contextFactory)
499                implements RequestHandlerInterface {
500                public function __construct(
501                    private CentralEngineWebController $controller,
502                    private string $moduleName,
503                    private string $action,
504                    private ?int $recordId,
505                    private PermissionContextFactory $contextFactory
506                ) {
507                }
508
509                public function handle(ServerRequestInterface $req): ResponseInterface
510                {
511                    $ctx = $this->contextFactory->createFromRequest($req);
512                    return match ($this->action) {
513                        'create' => $this->controller->actionCreate($req, $this->moduleName, $ctx),
514                        'detail' => $this->controller->actionDetail(
515                            $req,
516                            $this->moduleName,
517                            $this->recordId ?? 0,
518                            $ctx
519                        ),
520                        'edit'   => $this->controller->actionEdit($req, $this->moduleName, $this->recordId ?? 0, $ctx),
521                        default  => $this->controller->actionIndex($req, $this->moduleName, $ctx),
522                    };
523                }
524            }
525        );
526    }
527
528    /**
529     * Matches request URI path against active module route patterns.
530     *
531     * @param string                $trimPath      Sanitized path.
532     * @param array<ModuleMetadata> $activeModules List of active module metadata objects.
533     * @return array{0: ?ModuleMetadata, 1: string, 2: ?int} Matched module, action and optional record ID.
534     */
535    private function matchModuleRoute(string $trimPath, array $activeModules): array
536    {
537        foreach ($activeModules as $meta) {
538            $matched = $this->matchSingleModuleRoute($trimPath, $meta);
539            if ($matched !== null) {
540                return [$meta, $matched[0], $matched[1]];
541            }
542        }
543
544        return [null, 'index', null];
545    }
546
547    /**
548     * Matches path against candidate prefixes for a single module.
549     *
550     * @param string         $trimPath Sanitized request path.
551     * @param ModuleMetadata $meta     Module metadata.
552     * @return array{0: string, 1: ?int}|null Matched action and record ID or null.
553     */
554    private function matchSingleModuleRoute(string $trimPath, ModuleMetadata $meta): ?array
555    {
556        $basePath = '/' . trim($meta->routeUrl, '/');
557        $altPath = '/' . trim($meta->name, '/');
558        $dashPath = '/' . str_replace('_', '-', trim($meta->name, '/'));
559        $slashPath = '/' . str_replace('_', '/', trim($meta->name, '/'));
560        $legacyAliases = match ($meta->name) {
561            'user_password_resets' => ['/logs/password-resets'],
562            'system_users'         => ['/users', '/system/users'],
563            'system_profiles'      => ['/profiles', '/system/profiles'],
564            default                => [],
565        };
566        $candidates = array_unique([
567            $basePath,
568            $altPath,
569            $dashPath,
570            $slashPath,
571            ...$legacyAliases,
572            '/system' . $basePath,
573            '/engine' . $basePath,
574            '/engine' . $altPath,
575            '/records' . $basePath,
576            '/records' . $altPath,
577            '/app' . $basePath,
578            '/app' . $altPath,
579        ]);
580
581        foreach ($candidates as $prefix) {
582            $match = match (true) {
583                $trimPath === $prefix => ['index', null],
584                $trimPath === $prefix . '/create' => ['create', null],
585                (bool) preg_match('#^' . preg_quote($prefix, '#') . '/(?:detail/)?(\d+)$#', $trimPath, $m)
586                    => ['detail', (int) $m[1]],
587                (bool) preg_match('#^' . preg_quote($prefix, '#') . '/(\d+)/edit$#', $trimPath, $m)
588                    => ['edit', (int) $m[1]],
589                (bool) preg_match('#^' . preg_quote($prefix, '#') . '/edit/(\d+)$#', $trimPath, $m)
590                    => ['edit', (int) $m[1]],
591                default => null,
592            };
593            if ($match !== null) {
594                return $match;
595            }
596        }
597
598        return null;
599    }
600
601    /**
602     * Resolves redirect response if current module is not available in remote host context.
603     *
604     * @param ModuleMetadata        $matchedModule Matched module metadata.
605     * @param array<ModuleMetadata> $activeModules List of active module metadata objects.
606     * @return ResponseInterface|null Redirect response or null if accessible.
607     */
608    private function resolveRemoteRedirect(ModuleMetadata $matchedModule, array $activeModules): ?ResponseInterface
609    {
610        $isRemote = $this->instanceContextManager !== null && $this->instanceContextManager->isRemote();
611        $activeHostId = $isRemote ? 2 : 1;
612        if (!$isRemote || $matchedModule->isAvailableForHost($activeHostId)) {
613            return null;
614        }
615
616        foreach ($activeModules as $meta) {
617            if ($meta->isAvailableForHost($activeHostId)) {
618                $targetUrl = '/' . trim($meta->routeUrl, '/');
619                return $this->psr17Factory->createResponse(302)->withHeader('Location', $targetUrl);
620            }
621        }
622
623        return null;
624    }
625}