Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
88.82% |
302 / 340 |
|
72.73% |
16 / 22 |
CRAP | |
0.00% |
0 / 1 |
| HtmxEngineDispatchHandler | |
89.09% |
302 / 339 |
|
72.73% |
16 / 22 |
187.64 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| handle | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
| routeDataExchange | |
68.42% |
13 / 19 |
|
0.00% |
0 / 1 |
11.55 | |||
| routeLanguageTranslations | |
70.00% |
14 / 20 |
|
0.00% |
0 / 1 |
14.27 | |||
| routeMediaUpload | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| routePicklists | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
11 | |||
| routeModuleRelations | |
80.00% |
52 / 65 |
|
0.00% |
0 / 1 |
47.95 | |||
| routeGridRelations | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| handleGridRelationsList | |
90.32% |
28 / 31 |
|
0.00% |
0 / 1 |
10.09 | |||
| handleGridRelationDelete | |
33.33% |
4 / 12 |
|
0.00% |
0 / 1 |
12.41 | |||
| routeRelationMm | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
15 | |||
| routeTicketEmails | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| matchTicketEmailAction | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
8 | |||
| handleTicketEmailRescan | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| handleTicketEmailUnlink | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| handleTicketEmailLink | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| extractEmailIdFromRequest | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| handleTicketEmailPicker | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| handleTicketEmailList | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| renderTicketLinkedEmailCard | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
2.00 | |||
| createHtmlResponse | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| routeEngineCrud | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
15 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Engine\Presentation\Web; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Engine\Domain\Model\PermissionContext; |
| 12 | use Psr\Http\Message\ResponseInterface; |
| 13 | use Psr\Http\Message\ServerRequestInterface; |
| 14 | use 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 | */ |
| 24 | final 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 | } |