Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.82% covered (warning)
88.82%
302 / 340
72.73% covered (warning)
72.73%
16 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
HtmxEngineDispatchHandler
89.09% covered (warning)
89.09%
302 / 339
72.73% covered (warning)
72.73%
16 / 22
187.64
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
 handle
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 routeDataExchange
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
11.55
 routeLanguageTranslations
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
14.27
 routeMediaUpload
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 routePicklists
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
11
 routeModuleRelations
80.00% covered (warning)
80.00%
52 / 65
0.00% covered (danger)
0.00%
0 / 1
47.95
 routeGridRelations
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 handleGridRelationsList
90.32% covered (success)
90.32%
28 / 31
0.00% covered (danger)
0.00%
0 / 1
10.09
 handleGridRelationDelete
33.33% covered (danger)
33.33%
4 / 12
0.00% covered (danger)
0.00%
0 / 1
12.41
 routeRelationMm
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
15
 routeTicketEmails
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 matchTicketEmailAction
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 handleTicketEmailRescan
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 handleTicketEmailUnlink
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 handleTicketEmailLink
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 extractEmailIdFromRequest
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 handleTicketEmailPicker
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 handleTicketEmailList
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 renderTicketLinkedEmailCard
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 createHtmlResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 routeEngineCrud
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
15
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\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\PermissionContext;
12use Psr\Http\Message\ResponseInterface;
13use Psr\Http\Message\ServerRequestInterface;
14use Psr\Http\Server\RequestHandlerInterface;
15
16/**
17 * HTMX Engine Dispatch Handler.
18 *
19 * Inner request handler executing after authentication checks to route HTMX requests
20 * for Picklists, Module Relations, M:M Relations, and Central Engine CRUD.
21 *
22 * @package App\Core\Engine\Presentation\Web
23 */
24final readonly class HtmxEngineDispatchHandler implements RequestHandlerInterface
25{
26    private const string TEMPLATE_TICKET_LINKED_EMAIL_CARD = 'engine/partials/ticket_linked_email_card.twig';
27    private const string TEMPLATE_GRID_RELATIONS_TABLE = 'engine/partials/module_grid_relations_table.twig';
28    private const string MIME_HTML_UTF8 = 'text/html; charset=UTF-8';
29    private const string MIME_JSON_UTF8 = 'application/json; charset=UTF-8';
30
31    /**
32     * HtmxEngineDispatchHandler constructor.
33     *
34     * @param HtmxEngineRequestHandler $parent Parent request handler provider.
35     */
36    public function __construct(
37        private HtmxEngineRequestHandler $parent,
38    ) {
39    }
40
41    /**
42     * Handles incoming request by resolving permission context and matching routes.
43     *
44     * @param ServerRequestInterface $r PSR-7 server request.
45     * @return ResponseInterface PSR-7 response.
46     */
47    public function handle(ServerRequestInterface $r): ResponseInterface
48    {
49        $ctx = $this->parent->getContextFactory()->createFromRequest($r);
50
51        return $this->routeDataExchange($r, $ctx)
52            ?? $this->routePicklists($r, $ctx)
53            ?? $this->routeLanguageTranslations($r, $ctx)
54            ?? $this->routeMediaUpload($r)
55            ?? $this->routeGridRelations($r)
56            ?? $this->routeModuleRelations($r, $ctx)
57            ?? $this->routeRelationMm($r, $ctx)
58            ?? $this->routeTicketEmails($r)
59            ?? $this->routeEngineCrud($r, $ctx)
60            ?? $this->parent->getFactory()->createResponse(404);
61    }
62
63    /**
64     * Routes data exchange operations (Data Import & Data Export modals and streaming).
65     */
66    private function routeDataExchange(ServerRequestInterface $r, PermissionContext $ctx): ?ResponseInterface
67    {
68        $path = $this->parent->getPath();
69        $controller = $this->parent->getDataExchangeHtmxController();
70        if ($controller === null) {
71            return null;
72        }
73
74        return match (true) {
75            (bool) preg_match('#^/htmx/engine/([a-zA-Z0-9_-]+)/export-modal$#', $path, $m)
76                => $controller->actionExportModal($r, $m[1]),
77            (bool) preg_match('#^/htmx/engine/([a-zA-Z0-9_-]+)/export-download$#', $path, $m)
78                => $controller->actionExportDownload($r, $m[1], $ctx),
79            (bool) preg_match('#^/htmx/engine/([a-zA-Z0-9_-]+)/import-modal$#', $path, $m)
80                => $controller->actionImportModal($m[1]),
81            (bool) preg_match('#^/htmx/engine/([a-zA-Z0-9_-]+)/import-upload$#', $path, $m)
82                => $controller->actionUploadAndAnalyze($r, $m[1]),
83            (bool) preg_match('#^/htmx/engine/([a-zA-Z0-9_-]+)/import-start$#', $path, $m)
84                => $controller->actionStartImport($r, $m[1], $ctx),
85            (bool) preg_match('#^/htmx/engine/([a-zA-Z0-9_-]+)/import-progress/(\d+)$#', $path, $m)
86                => $controller->actionImportProgress($m[1], (int) $m[2]),
87            default => null,
88        };
89    }
90
91    /**
92     * Routes language translations listing, inline editing, adding and resetting.
93     */
94    private function routeLanguageTranslations(ServerRequestInterface $r, PermissionContext $ctx): ?ResponseInterface
95    {
96        $path = $this->parent->getPath();
97        $api = $this->parent->getLanguageTranslationsApi();
98        if ($api === null) {
99            return null;
100        }
101        $factory = $this->parent->getFactory();
102
103        return match (true) {
104            (bool) preg_match('#^/htmx/engine/system_languages/(\d+)/translations/(\d+)$#', $path, $m)
105                => match ($r->getMethod()) {
106                    'PUT'    => $api->update($r, (int) $m[1], (int) $m[2], $ctx),
107                    'DELETE' => $api->delete((int) $m[1], (int) $m[2]),
108                    default  => $factory->createResponse(405),
109                },
110            (bool) preg_match('#^/htmx/engine/system_languages/(\d+)/translations$#', $path, $m)
111                => match ($r->getMethod()) {
112                    'GET'    => $api->list($r, (int) $m[1]),
113                    'POST'   => $api->create($r, (int) $m[1], $ctx),
114                    default  => $factory->createResponse(405),
115                },
116            default => null,
117        };
118    }
119
120    /**
121     * Routes asynchronous media uploads.
122     */
123    private function routeMediaUpload(ServerRequestInterface $r): ?ResponseInterface
124    {
125        $path = $this->parent->getPath();
126        $mediaApi = $this->parent->getMediaUploadApi();
127
128        if ($mediaApi !== null && $path === '/htmx/engine/media/upload' && $r->getMethod() === 'POST') {
129            return $mediaApi->actionUpload($r);
130        }
131
132        return null;
133    }
134
135    /**
136     * Routes picklist value reordering, CRUD and item management.
137     */
138    private function routePicklists(ServerRequestInterface $r, PermissionContext $ctx): ?ResponseInterface
139    {
140        $path = $this->parent->getPath();
141        $api = $this->parent->getPicklistValuesApi();
142        $factory = $this->parent->getFactory();
143
144        return match (true) {
145            (bool) preg_match('#^/htmx/engine/system_picklists/(\d+)/values/reorder$#', $path, $m)
146                => $api->reorder($r, (int) $m[1]),
147            (bool) preg_match('#^/htmx/engine/system_picklists/(\d+)/values/(\d+)$#', $path, $m)
148                => match ($r->getMethod()) {
149                    'PUT'    => $api->update($r, (int) $m[1], (int) $m[2]),
150                    'DELETE' => $api->delete($r, (int) $m[1], (int) $m[2]),
151                    default  => $factory->createResponse(405),
152                },
153            (bool) preg_match('#^/htmx/engine/system_picklists/(\d+)/values$#', $path, $m)
154                => match ($r->getMethod()) {
155                    'GET'   => $api->list((int) $m[1]),
156                    'POST'  => $api->create($r, (int) $m[1], $ctx),
157                    default => $factory->createResponse(405),
158                },
159            default => null,
160        };
161    }
162
163    /**
164     * Routes module fields reordering and relation management endpoints.
165     */
166    private function routeModuleRelations(ServerRequestInterface $r, PermissionContext $ctx): ?ResponseInterface
167    {
168        $path = $this->parent->getPath();
169        $fieldsApi = $this->parent->getModuleFieldsApi();
170        $relationsApi = $this->parent->getModuleRelationsApi();
171        $factory = $this->parent->getFactory();
172
173        return match (true) {
174            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/fields/reorder$#', $path, $m)
175                => $fieldsApi->reorder($r, (int) $m[1]),
176            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/fields/(\d+)/toggle-global-search$#', $path, $m)
177                => match ($r->getMethod()) {
178                    'POST'  => $fieldsApi->toggleGlobalSearch((int) $m[1], (int) $m[2]),
179                    default => $factory->createResponse(405),
180                },
181            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/fields/(\d+)/toggle-quick-create$#', $path, $m)
182                => match ($r->getMethod()) {
183                    'POST'  => $fieldsApi->toggleQuickCreate((int) $m[1], (int) $m[2]),
184                    default => $factory->createResponse(405),
185                },
186            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/fields/(\d+)/display-mode$#', $path, $m)
187                => match ($r->getMethod()) {
188                    'POST'  => $fieldsApi->updateDisplayMode($r, (int) $m[1], (int) $m[2]),
189                    default => $factory->createResponse(405),
190                },
191            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/fields/(\d+)$#', $path, $m)
192                => match ($r->getMethod()) {
193                    'DELETE' => $fieldsApi->delete((int) $m[1], (int) $m[2]),
194                    default  => $factory->createResponse(405),
195                },
196            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/fields$#', $path, $m)
197                => match ($r->getMethod()) {
198                    'GET'    => $fieldsApi->list((int) $m[1]),
199                    'POST'   => $fieldsApi->create($r, (int) $m[1], $ctx),
200                    default  => $factory->createResponse(405),
201                },
202            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/filters/(\d+)$#', $path, $m)
203                => match ($r->getMethod()) {
204                    'DELETE' => $relationsApi->deleteFilter((int) $m[1], (int) $m[2]),
205                    default  => $factory->createResponse(405),
206                },
207            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/filters$#', $path, $m)
208                => match ($r->getMethod()) {
209                    'GET'   => $relationsApi->listFilters((int) $m[1]),
210                    default => $factory->createResponse(405),
211                },
212            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/relations/filters$#', $path, $m)
213                => match ($r->getMethod()) {
214                    'GET'   => $relationsApi->availableFilters((int) $m[1]),
215                    default => $factory->createResponse(405),
216                },
217            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/picklists/(\d+)$#', $path, $m)
218                => match ($r->getMethod()) {
219                    'DELETE' => $relationsApi->deletePicklist((int) $m[1], (int) $m[2]),
220                    default  => $factory->createResponse(405),
221                },
222            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/picklists$#', $path, $m)
223                => match ($r->getMethod()) {
224                    'GET'   => $relationsApi->listPicklists((int) $m[1]),
225                    default => $factory->createResponse(405),
226                },
227            (bool) preg_match('#^/htmx/engine/system_modules/(\d+)/relations/picklists$#', $path, $m)
228                => match ($r->getMethod()) {
229                    'GET'   => $relationsApi->availablePicklists((int) $m[1]),
230                    default => $factory->createResponse(405),
231                },
232            default => null,
233        };
234    }
235
236    /**
237     * Routes GRID widget relations listing and deletion for modules and widgets.
238     */
239    private function routeGridRelations(ServerRequestInterface $r): ?ResponseInterface
240    {
241        $path = $this->parent->getPath();
242        if ($path === '/htmx/engine/system_relations_grid/list') {
243            return $this->handleGridRelationsList($r);
244        }
245
246        if (preg_match('#^/htmx/engine/system_relations_grid/(\d+)$#', $path, $m) && $r->getMethod() === 'DELETE') {
247            return $this->handleGridRelationDelete((int) $m[1], $r);
248        }
249
250        return null;
251    }
252
253    /**
254     * Handles listing of GRID widget placement relations.
255     */
256    private function handleGridRelationsList(ServerRequestInterface $r): ResponseInterface
257    {
258        $queryParams = $r->getQueryParams();
259        $moduleId = isset($queryParams['module_id']) && is_numeric($queryParams['module_id'])
260            ? (int) $queryParams['module_id']
261            : null;
262        $widgetId = isset($queryParams['widget_id']) && is_numeric($queryParams['widget_id'])
263            ? (int) $queryParams['widget_id']
264            : null;
265        $returnUrl = (string) ($queryParams['return_url'] ?? '');
266
267        $records = $this->parent->getModuleRelationsApi()->listGridRelations($moduleId, $widgetId);
268
269        $acceptHeader = $r->getHeaderLine('Accept');
270        $isHtmx = $r->hasHeader('HX-Request');
271
272        if (!$isHtmx && str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/html')) {
273            $res = $this->parent->getFactory()->createResponse(200)
274                ->withHeader('Content-Type', self::MIME_JSON_UTF8);
275            $res->getBody()->write((string) json_encode([
276                'success' => true,
277                'status'  => true,
278                'data'    => ['records' => $records, 'total' => count($records)],
279            ], JSON_UNESCAPED_UNICODE));
280            return $res;
281        }
282
283        $twig = $this->parent->getTwig();
284        if ($twig === null) {
285            return $this->createHtmlResponse('');
286        }
287
288        $html = $twig->render(self::TEMPLATE_GRID_RELATIONS_TABLE, [
289            'records'        => $records,
290            'module_id'      => $moduleId,
291            'widget_id'      => $widgetId,
292            'return_url'     => $returnUrl,
293            'is_widget_view' => $widgetId !== null && $widgetId > 0,
294        ]);
295
296        return $this->createHtmlResponse($html);
297    }
298
299    /**
300     * Handles deletion of a GRID widget placement relation.
301     */
302    private function handleGridRelationDelete(int $relationId, ServerRequestInterface $r): ResponseInterface
303    {
304        $deleted = $this->parent->getModuleRelationsApi()->deleteGridRelation($relationId);
305        $isHtmx = $r->hasHeader('HX-Request');
306
307        if ($isHtmx) {
308            return $this->createHtmlResponse('', $deleted ? 200 : 404);
309        }
310
311        $statusCode = $deleted ? 200 : 404;
312        $payload = $deleted
313            ? ['success' => true, 'data' => ['deleted_id' => $relationId]]
314            : ['success' => false, 'error' => 'Relation not found'];
315
316        $res = $this->parent->getFactory()->createResponse($statusCode)
317            ->withHeader('Content-Type', self::MIME_JSON_UTF8);
318        $res->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE));
319        return $res;
320    }
321
322    /**
323     * Routes many-to-many relationship actions.
324     */
325    private function routeRelationMm(ServerRequestInterface $r, PermissionContext $ctx): ?ResponseInterface
326    {
327        $relationMmApi = $this->parent->getRelationMmApi();
328        if ($relationMmApi === null) {
329            return null;
330        }
331
332        $path = $this->parent->getPath();
333        $factory = $this->parent->getFactory();
334
335        return match (true) {
336            (bool) preg_match(
337                '#^/htmx/engine/([a-z0-9_]+)/(\d+)/relations/([a-z0-9_]+)/picker$#',
338                $path,
339                $m
340            ) => match ($r->getMethod()) {
341                'GET'   => $relationMmApi->picker($r, $m[1], (int) $m[2], $m[3]),
342                default => $factory->createResponse(405),
343            },
344            (bool) preg_match(
345                '#^/htmx/engine/([a-z0-9_]+)/(\d+)/relations/([a-z0-9_]+)/link$#',
346                $path,
347                $m
348            ) => match ($r->getMethod()) {
349                'POST'  => $relationMmApi->link($r, $m[1], (int) $m[2], $m[3], $ctx),
350                default => $factory->createResponse(405),
351            },
352            (bool) preg_match(
353                '#^/htmx/engine/([a-z0-9_]+)/(\d+)/relations/([a-z0-9_]+)/unlink/(\d+)$#',
354                $path,
355                $m
356            ) => match ($r->getMethod()) {
357                'DELETE', 'POST' => $relationMmApi->unlink($m[1], (int) $m[2], $m[3], (int) $m[4]),
358                default          => $factory->createResponse(405),
359            },
360            (bool) preg_match(
361                '#^/htmx/engine/([a-z0-9_]+)/(\d+)/relations/([a-z0-9_]+)$#',
362                $path,
363                $m
364            ) => match ($r->getMethod()) {
365                'GET'   => $relationMmApi->list($m[1], (int) $m[2], $m[3]),
366                default => $factory->createResponse(405),
367            },
368            default => null,
369        };
370    }
371
372    /**
373     * Routes ticket-linked email operations (list, unlink, link, picker).
374     */
375    private function routeTicketEmails(ServerRequestInterface $r): ?ResponseInterface
376    {
377        if ($this->parent->getTwig() === null) {
378            return null;
379        }
380
381        return $this->matchTicketEmailAction($this->parent->getPath(), $r);
382    }
383
384    private function matchTicketEmailAction(string $path, ServerRequestInterface $r): ?ResponseInterface
385    {
386        if (!preg_match('#^/htmx/engine/([a-z0-9_]+)/(\d+)/emails(?:/(.+))?$#', $path, $m)) {
387            return null;
388        }
389
390        $module = $m[1];
391        $recordId = (int) $m[2];
392        $sub = $m[3] ?? '';
393
394        return match (true) {
395            $sub === 'rescan' => $this->handleTicketEmailRescan($r, $module, $recordId),
396            str_starts_with($sub, 'unlink/') => $this->handleTicketEmailUnlink(
397                $r,
398                $module,
399                $recordId,
400                (int) substr($sub, 7)
401            ),
402            $sub === 'link' => $this->handleTicketEmailLink($r, $module, $recordId),
403            $sub === 'picker' => $this->handleTicketEmailPicker($r, $module, $recordId),
404            $sub === '' => $this->handleTicketEmailList($r, $module, $recordId),
405            default => null,
406        };
407    }
408
409    private function handleTicketEmailRescan(
410        ServerRequestInterface $r,
411        string $moduleName,
412        int $recordId
413    ): ResponseInterface {
414        if ($r->getMethod() !== 'POST') {
415            return $this->parent->getFactory()->createResponse(405);
416        }
417
418        $engineApi = $this->parent->getEngineApi();
419        $rescanResult = $engineApi->rescanAndLinkEmailsForRecord($moduleName, $recordId);
420        $linkedEmails = $rescanResult['linked_emails'];
421        $matchedCount = $rescanResult['matched_count'];
422        $html = $this->renderTicketLinkedEmailCard($moduleName, $recordId, $linkedEmails);
423
424        $triggerPayload = json_encode(['emailRescanned' => ['matchedCount' => $matchedCount]]);
425        return $this->createHtmlResponse($html)
426            ->withHeader('X-Rescan-Matched', (string) $matchedCount)
427            ->withHeader('HX-Trigger', is_string($triggerPayload) ? $triggerPayload : '{}');
428    }
429
430    private function handleTicketEmailUnlink(
431        ServerRequestInterface $r,
432        string $moduleName,
433        int $recordId,
434        int $emailId
435    ): ResponseInterface {
436        if ($r->getMethod() !== 'POST' && $r->getMethod() !== 'DELETE') {
437            return $this->parent->getFactory()->createResponse(405);
438        }
439
440        $this->parent->getEngineApi()->unlinkEmailFromRecord($moduleName, $recordId, $emailId);
441        $html = $this->renderTicketLinkedEmailCard($moduleName, $recordId);
442
443        return $this->createHtmlResponse($html);
444    }
445
446    private function handleTicketEmailLink(
447        ServerRequestInterface $r,
448        string $moduleName,
449        int $recordId
450    ): ResponseInterface {
451        if ($r->getMethod() !== 'POST') {
452            return $this->parent->getFactory()->createResponse(405);
453        }
454
455        $emailId = $this->extractEmailIdFromRequest($r);
456        if ($emailId > 0) {
457            $this->parent->getEngineApi()->linkEmailToRecord($moduleName, $recordId, $emailId);
458        }
459
460        $html = $this->renderTicketLinkedEmailCard($moduleName, $recordId);
461
462        return $this->createHtmlResponse($html);
463    }
464
465    private function extractEmailIdFromRequest(ServerRequestInterface $r): int
466    {
467        $body = (array) ($r->getParsedBody() ?? []);
468        $emailId = isset($body['email_id']) ? (int) $body['email_id'] : 0;
469        if ($emailId > 0) {
470            return $emailId;
471        }
472
473        $raw = (string) $r->getBody();
474        $json = json_decode($raw, true);
475        if (is_array($json) && isset($json['email_id'])) {
476            return (int) $json['email_id'];
477        }
478
479        return 0;
480    }
481
482    private function handleTicketEmailPicker(
483        ServerRequestInterface $r,
484        string $moduleName,
485        int $recordId
486    ): ResponseInterface {
487        if ($r->getMethod() !== 'GET') {
488            return $this->parent->getFactory()->createResponse(405);
489        }
490
491        $queryParams = $r->getQueryParams();
492        $query = isset($queryParams['q']) ? (string) $queryParams['q'] : null;
493        $candidates = $this->parent->getEngineApi()->fetchAvailableEmailsToLink($moduleName, $query);
494
495        $twig = $this->parent->getTwig();
496        $html = $twig !== null ? $twig->render('engine/partials/modal_ticket_email_picker.twig', [
497            'candidates' => $candidates,
498            'ticket_id'  => $recordId,
499            'query'      => $query ?? '',
500        ]) : '';
501
502        return $this->createHtmlResponse($html);
503    }
504
505    private function handleTicketEmailList(
506        ServerRequestInterface $r,
507        string $moduleName,
508        int $recordId
509    ): ResponseInterface {
510        if ($r->getMethod() !== 'GET') {
511            return $this->parent->getFactory()->createResponse(405);
512        }
513
514        $html = $this->renderTicketLinkedEmailCard($moduleName, $recordId);
515
516        return $this->createHtmlResponse($html);
517    }
518
519    /**
520     * @param array<int, array<string, mixed>>|null $linkedEmails
521     */
522    private function renderTicketLinkedEmailCard(
523        string $moduleName,
524        int $recordId,
525        ?array $linkedEmails = null
526    ): string {
527        $engineApi = $this->parent->getEngineApi();
528        $twig = $this->parent->getTwig();
529        if ($twig === null) {
530            return '';
531        }
532
533        $linkedEmails ??= $engineApi->fetchLinkedEmailsForRecord($moduleName, $recordId);
534        $ticketCtx = $engineApi->fetchTicketEmailContext($moduleName, $recordId);
535        $mailboxes = $engineApi->fetchActiveMailboxes();
536
537        return $twig->render(self::TEMPLATE_TICKET_LINKED_EMAIL_CARD, [
538            'linked_emails'  => $linkedEmails,
539            'linked_email'   => $linkedEmails[0] ?? null,
540            'record_id'      => $recordId,
541            'module_name'    => $moduleName,
542            'ticket_context' => $ticketCtx,
543            'mailboxes'      => $mailboxes,
544        ]);
545    }
546
547    private function createHtmlResponse(string $html, int $status = 200): ResponseInterface
548    {
549        $res = $this->parent->getFactory()->createResponse($status)
550            ->withHeader('Content-Type', self::MIME_HTML_UTF8);
551        $res->getBody()->write($html);
552
553        return $res;
554    }
555
556    /**
557     * Routes central engine CRUD endpoints.
558     */
559    private function routeEngineCrud(ServerRequestInterface $r, PermissionContext $ctx): ?ResponseInterface
560    {
561        $path = $this->parent->getPath();
562        $engineApi = $this->parent->getEngineApi();
563        $factory = $this->parent->getFactory();
564
565        return match (true) {
566            (bool) preg_match('#^/htmx/engine/([a-z0-9_]+)/list$#', $path, $m)
567                => $engineApi->actionList($r, $m[1], $ctx),
568            (bool) preg_match('#^/htmx/engine/([a-z0-9_]+)/schema$#', $path, $m)
569                => $engineApi->actionSchema($m[1], $ctx),
570            (bool) preg_match('#^/htmx/engine/([a-z0-9_]+)/(\d+)/timeline$#', $path, $m)
571                => $engineApi->actionTimeline($m[1], (int) $m[2], $ctx, $r),
572            (bool) preg_match('#^/htmx/engine/([a-z0-9_]+)/(\d+)/status/(\d+)$#', $path, $m)
573                => $engineApi->actionStatus($m[1], (int) $m[2], (int) $m[3], $ctx),
574            (bool) preg_match('#^/htmx/engine/([a-z0-9_]+)/(\d+)$#', $path, $m)
575                => match ($r->getMethod()) {
576                    'GET'         => $engineApi->actionRead($m[1], (int) $m[2], $ctx),
577                    'PUT', 'POST' => $engineApi->actionUpdate($r, $m[1], (int) $m[2], $ctx),
578                    'DELETE'      => $engineApi->actionDelete($m[1], (int) $m[2], $ctx),
579                    default       => $factory->createResponse(405),
580                },
581            (bool) preg_match('#^/htmx/engine/([a-z0-9_]+)$#', $path, $m)
582                => match ($r->getMethod()) {
583                    'GET'    => $engineApi->actionList($r, $m[1], $ctx),
584                    'POST'   => $engineApi->actionCreate($r, $m[1], $ctx),
585                    default  => $factory->createResponse(405),
586                },
587            default => null,
588        };
589    }
590}