Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.63% |
176 / 190 |
|
40.00% |
4 / 10 |
CRAP | |
0.00% |
0 / 1 |
| CentralEngineWebController | |
93.12% |
176 / 189 |
|
40.00% |
4 / 10 |
63.25 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| actionIndex | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
7 | |||
| actionCreate | |
100.00% |
54 / 54 |
|
100.00% |
1 / 1 |
17 | |||
| actionDetail | |
97.56% |
40 / 41 |
|
0.00% |
0 / 1 |
10 | |||
| actionEdit | |
96.55% |
28 / 29 |
|
0.00% |
0 / 1 |
8 | |||
| htmlResponse | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| resolveQueryParams | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| resolveInitialCalendarDates | |
54.55% |
6 / 11 |
|
0.00% |
0 / 1 |
3.85 | |||
| resolvePresentationMode | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
3.58 | |||
| assertCanEditRecord | |
81.25% |
13 / 16 |
|
0.00% |
0 / 1 |
9.53 | |||
| 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\Exception\ModuleNotFoundException; |
| 12 | use App\Core\Engine\Domain\Exception\PermissionDeniedException; |
| 13 | use App\Core\Engine\Domain\Exception\RecordNotFoundException; |
| 14 | use App\Core\Engine\Domain\Model\ModuleMetadata; |
| 15 | use App\Core\Engine\Domain\Model\PermissionContext; |
| 16 | use App\Core\Engine\Domain\Model\RecordStatus; |
| 17 | use App\Core\Engine\Presentation\Api\CentralEngineApiController; |
| 18 | use App\Core\Engine\Presentation\Web\Support\RecordCreationPrefillResolver; |
| 19 | use App\Core\Engine\Presentation\Web\Support\RecordTitleResolver; |
| 20 | use App\Core\Grid\GridRequest; |
| 21 | use Nyholm\Psr7\Factory\Psr17Factory; |
| 22 | use Psr\Http\Message\ResponseInterface; |
| 23 | use Psr\Http\Message\ServerRequestInterface; |
| 24 | use Twig\Environment as TwigEnvironment; |
| 25 | |
| 26 | /** |
| 27 | * Central Engine Web Controller. |
| 28 | * |
| 29 | * Full-page HTML controller serving module list, detail, create, and edit views. |
| 30 | * Consumes CentralEngineApiController adhering strictly to API-First BFF architecture. |
| 31 | * |
| 32 | * @package App\Core\Engine\Presentation\Web |
| 33 | */ |
| 34 | final readonly class CentralEngineWebController |
| 35 | { |
| 36 | private const string HTML_CONTENT_TYPE = 'text/html; charset=UTF-8'; |
| 37 | private const string DATETIME_FORMAT = 'Y-m-d H:i:s'; |
| 38 | |
| 39 | private RecordCreationPrefillResolver $prefillResolver; |
| 40 | private RecordTitleResolver $titleResolver; |
| 41 | |
| 42 | /** |
| 43 | * CentralEngineWebController constructor. |
| 44 | * |
| 45 | * @param TwigEnvironment $twig Twig template engine. |
| 46 | * @param Psr17Factory $psr17 PSR-17 response factory. |
| 47 | * @param CentralEngineApiController $engineApi Central Engine API Controller instance. |
| 48 | * @param RecordCreationPrefillResolver|null $prefillResolver Prefill and cascade resolver helper. |
| 49 | * @param RecordTitleResolver|null $titleResolver Record display title resolver helper. |
| 50 | */ |
| 51 | public function __construct( |
| 52 | private TwigEnvironment $twig, |
| 53 | private Psr17Factory $psr17, |
| 54 | private CentralEngineApiController $engineApi, |
| 55 | ?RecordCreationPrefillResolver $prefillResolver = null, |
| 56 | ?RecordTitleResolver $titleResolver = null, |
| 57 | ) { |
| 58 | $this->prefillResolver = $prefillResolver ?? new RecordCreationPrefillResolver($engineApi); |
| 59 | $this->titleResolver = $titleResolver ?? new RecordTitleResolver(); |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * Renders the universal module grid page for the given module name. |
| 64 | * |
| 65 | * @param ServerRequestInterface $request PSR-7 request. |
| 66 | * @param string $moduleName Module machine name. |
| 67 | * @param PermissionContext $context Security context. |
| 68 | * @return ResponseInterface HTML response with rendered module grid. |
| 69 | */ |
| 70 | public function actionIndex( |
| 71 | ServerRequestInterface $request, |
| 72 | string $moduleName, |
| 73 | PermissionContext $context |
| 74 | ): ResponseInterface { |
| 75 | try { |
| 76 | $queryParams = $request->getQueryParams(); |
| 77 | $filterId = isset($queryParams['filter']) ? (int) $queryParams['filter'] : null; |
| 78 | $gridFilterId = isset($queryParams['grid_filter']) ? (int) $queryParams['grid_filter'] : null; |
| 79 | $level = isset($queryParams['level']) && is_string($queryParams['level']) |
| 80 | ? trim($queryParams['level']) |
| 81 | : null; |
| 82 | |
| 83 | $gridContext = $this->engineApi->fetchGridContext( |
| 84 | $moduleName, |
| 85 | $filterId, |
| 86 | $context, |
| 87 | $gridFilterId, |
| 88 | $level |
| 89 | ); |
| 90 | |
| 91 | $viewData = array_merge($gridContext, [ |
| 92 | 'request' => $request, |
| 93 | 'current_user_id' => $context->actorUserId, |
| 94 | 'is_superuser' => $context->isSuperuser, |
| 95 | ]); |
| 96 | |
| 97 | return $this->htmlResponse($this->twig->render('engine/grid.twig', $viewData)); |
| 98 | } catch (ModuleNotFoundException) { |
| 99 | return $this->psr17->createResponse(404); |
| 100 | } catch (PermissionDeniedException) { |
| 101 | return $this->psr17->createResponse(403); |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Renders the universal record creation form page. |
| 107 | * |
| 108 | * @param ServerRequestInterface $request PSR-7 request. |
| 109 | * @param string $moduleName Module machine name. |
| 110 | * @param PermissionContext $context Security context. |
| 111 | * @return ResponseInterface HTML response with rendered creation form. |
| 112 | */ |
| 113 | public function actionCreate( |
| 114 | ServerRequestInterface $request, |
| 115 | string $moduleName, |
| 116 | PermissionContext $context |
| 117 | ): ResponseInterface { |
| 118 | try { |
| 119 | $moduleContext = $this->engineApi->fetchModuleContext($moduleName, $context, true); |
| 120 | |
| 121 | $queryParams = $this->resolveQueryParams($request); |
| 122 | $duplicateFromId = isset($queryParams['duplicate_from']) ? (int) $queryParams['duplicate_from'] : null; |
| 123 | $returnUrl = isset($queryParams['return_url']) ? (string) $queryParams['return_url'] : null; |
| 124 | $initialRecord = $this->prefillResolver->resolveDuplicateInitialRecord( |
| 125 | $moduleName, |
| 126 | $duplicateFromId, |
| 127 | $context |
| 128 | ); |
| 129 | if (isset($queryParams['module_id']) && !isset($initialRecord['module_id'])) { |
| 130 | $initialRecord['module_id'] = (int) $queryParams['module_id']; |
| 131 | } |
| 132 | $sourceModule = isset($queryParams['source_module']) ? (string) $queryParams['source_module'] : null; |
| 133 | $sourceId = isset($queryParams['source_id']) ? (int) $queryParams['source_id'] : null; |
| 134 | |
| 135 | $fieldsList = $moduleContext['fields'] ?? []; |
| 136 | $this->prefillResolver->resolveSourceRelationKey($sourceModule, $sourceId, $fieldsList, $initialRecord); |
| 137 | $this->prefillResolver->populateQueryParamsIntoInitialRecord($fieldsList, $queryParams, $initialRecord); |
| 138 | |
| 139 | $this->prefillResolver->resolvePolymorphicPrefills( |
| 140 | $moduleName, |
| 141 | $sourceModule, |
| 142 | $sourceId, |
| 143 | $initialRecord, |
| 144 | $queryParams, |
| 145 | $context |
| 146 | ); |
| 147 | |
| 148 | if ($moduleName === 'calendar') { |
| 149 | $this->resolveInitialCalendarDates($initialRecord); |
| 150 | } |
| 151 | |
| 152 | $isModal = isset($queryParams['modal']) && (string) $queryParams['modal'] === '1'; |
| 153 | $isPopup = isset($queryParams['popup']) && (string) $queryParams['popup'] === '1'; |
| 154 | $template = match (true) { |
| 155 | $isModal => 'engine/modal_create.twig', |
| 156 | $isPopup => 'engine/popup_create.twig', |
| 157 | default => 'engine/create.twig', |
| 158 | }; |
| 159 | |
| 160 | $moduleObj = $moduleContext['module'] ?? null; |
| 161 | $defaultMode = ($moduleObj instanceof ModuleMetadata) ? $moduleObj->defaultCreateMode : 'standard'; |
| 162 | $createMode = isset($queryParams['create_mode']) ? (string) $queryParams['create_mode'] : $defaultMode; |
| 163 | $presentationMode = $this->resolvePresentationMode($moduleObj, $queryParams); |
| 164 | |
| 165 | $viewData = array_merge($moduleContext, [ |
| 166 | 'request' => $request, |
| 167 | 'record' => $initialRecord, |
| 168 | 'duplicate_from' => $duplicateFromId, |
| 169 | 'current_user_id' => $context->actorUserId, |
| 170 | 'is_superuser' => $context->isSuperuser, |
| 171 | 'return_url' => $returnUrl, |
| 172 | 'source_module' => $sourceModule, |
| 173 | 'source_id' => $sourceId, |
| 174 | 'create_mode' => $createMode, |
| 175 | 'presentation_mode' => $presentationMode, |
| 176 | ]); |
| 177 | |
| 178 | return $this->htmlResponse($this->twig->render($template, $viewData)); |
| 179 | } catch (ModuleNotFoundException) { |
| 180 | return $this->psr17->createResponse(404); |
| 181 | } catch (PermissionDeniedException) { |
| 182 | return $this->psr17->createResponse(403); |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | /** |
| 187 | * Renders the universal record detail / preview page. |
| 188 | * |
| 189 | * @param ServerRequestInterface $request PSR-7 request. |
| 190 | * @param string $moduleName Module machine name. |
| 191 | * @param int $id Record primary key. |
| 192 | * @param PermissionContext $context Security context. |
| 193 | * @return ResponseInterface HTML response with rendered record details. |
| 194 | */ |
| 195 | public function actionDetail( |
| 196 | ServerRequestInterface $request, |
| 197 | string $moduleName, |
| 198 | int $id, |
| 199 | PermissionContext $context |
| 200 | ): ResponseInterface { |
| 201 | try { |
| 202 | $queryParams = $this->resolveQueryParams($request); |
| 203 | $returnUrl = isset($queryParams['return_url']) ? (string) $queryParams['return_url'] : null; |
| 204 | $level = isset($queryParams['level']) && is_string($queryParams['level']) |
| 205 | ? trim($queryParams['level']) |
| 206 | : null; |
| 207 | $moduleContext = $this->engineApi->fetchModuleContext($moduleName, $context); |
| 208 | $record = $this->engineApi->fetchRecord($moduleName, $id, $context, $level); |
| 209 | $title = $this->titleResolver->resolveTitle($moduleContext['fields'], $record, $id); |
| 210 | |
| 211 | $gridRequest = GridRequest::fromRequest($request); |
| 212 | $rawFilter = $queryParams['filter'] ?? $queryParams['filter_id'] ?? null; |
| 213 | $filterId = $rawFilter !== null ? (int) $rawFilter : null; |
| 214 | $navigation = $this->engineApi->fetchNavigation($moduleName, $id, $gridRequest, $context, $filterId); |
| 215 | $hierarchy = $this->engineApi->fetchHierarchy($moduleName, $id, $context); |
| 216 | $supportsEmails = in_array($moduleName, ['tickets', 'contacts', 'companies'], true); |
| 217 | $linkedEmails = $supportsEmails ? $this->engineApi->fetchLinkedEmailsForRecord($moduleName, $id) : []; |
| 218 | $linkedEmail = $linkedEmails[0] ?? null; |
| 219 | $ticketContext = $supportsEmails ? $this->engineApi->fetchTicketEmailContext($moduleName, $id) : null; |
| 220 | $mailboxes = $supportsEmails ? $this->engineApi->fetchActiveMailboxes() : []; |
| 221 | |
| 222 | $viewData = array_merge($moduleContext, [ |
| 223 | 'request' => $request, |
| 224 | 'record' => $record, |
| 225 | 'record_id' => $id, |
| 226 | 'record_title' => $title, |
| 227 | 'linked_emails' => $linkedEmails, |
| 228 | 'linked_email' => $linkedEmail, |
| 229 | 'ticket_context' => $ticketContext, |
| 230 | 'mailboxes' => $mailboxes, |
| 231 | 'navigation' => $navigation, |
| 232 | 'hierarchy' => $hierarchy, |
| 233 | 'hierarchy_count' => $hierarchy['related_count'] ?? 0, |
| 234 | 'has_hierarchy' => $hierarchy['has_hierarchy'] ?? false, |
| 235 | 'current_user_id' => $context->actorUserId, |
| 236 | 'return_url' => $returnUrl, |
| 237 | 'query_params' => $queryParams, |
| 238 | 'query_string' => $request->getUri()->getQuery(), |
| 239 | ]); |
| 240 | |
| 241 | return $this->htmlResponse($this->twig->render('engine/detail.twig', $viewData)); |
| 242 | } catch (ModuleNotFoundException | RecordNotFoundException) { |
| 243 | return $this->psr17->createResponse(404); |
| 244 | } catch (PermissionDeniedException) { |
| 245 | return $this->psr17->createResponse(403); |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | /** |
| 250 | * Renders the universal record edit form page. |
| 251 | * |
| 252 | * @param ServerRequestInterface $request PSR-7 request. |
| 253 | * @param string $moduleName Module machine name. |
| 254 | * @param int $id Record primary key. |
| 255 | * @param PermissionContext $context Security context. |
| 256 | * @return ResponseInterface HTML response with rendered edit form. |
| 257 | */ |
| 258 | public function actionEdit( |
| 259 | ServerRequestInterface $request, |
| 260 | string $moduleName, |
| 261 | int $id, |
| 262 | PermissionContext $context |
| 263 | ): ResponseInterface { |
| 264 | try { |
| 265 | $queryParams = $this->resolveQueryParams($request); |
| 266 | $returnUrl = isset($queryParams['return_url']) ? (string) $queryParams['return_url'] : null; |
| 267 | $level = isset($queryParams['level']) && is_string($queryParams['level']) |
| 268 | ? trim($queryParams['level']) |
| 269 | : null; |
| 270 | $moduleContext = $this->engineApi->fetchModuleContext($moduleName, $context, true); |
| 271 | $record = $this->engineApi->fetchRecord($moduleName, $id, $context, $level); |
| 272 | $this->assertCanEditRecord($moduleName, $record, $context); |
| 273 | $title = $this->titleResolver->resolveTitle($moduleContext['fields'], $record, $id); |
| 274 | $hierarchy = $this->engineApi->fetchHierarchy($moduleName, $id, $context); |
| 275 | |
| 276 | $isModal = isset($queryParams['modal']) && (string) $queryParams['modal'] === '1'; |
| 277 | $template = $isModal ? 'engine/modal_create.twig' : 'engine/edit.twig'; |
| 278 | |
| 279 | $viewData = array_merge($moduleContext, [ |
| 280 | 'request' => $request, |
| 281 | 'record' => $record, |
| 282 | 'record_id' => $id, |
| 283 | 'record_title' => $title, |
| 284 | 'hierarchy' => $hierarchy, |
| 285 | 'hierarchy_count' => $hierarchy['related_count'] ?? 0, |
| 286 | 'has_hierarchy' => $hierarchy['has_hierarchy'] ?? false, |
| 287 | 'current_user_id' => $context->actorUserId, |
| 288 | 'is_superuser' => $context->isSuperuser, |
| 289 | 'return_url' => $returnUrl, |
| 290 | ]); |
| 291 | |
| 292 | return $this->htmlResponse($this->twig->render($template, $viewData)); |
| 293 | } catch (ModuleNotFoundException | RecordNotFoundException) { |
| 294 | return $this->psr17->createResponse(404); |
| 295 | } catch (PermissionDeniedException) { |
| 296 | return $this->psr17->createResponse(403); |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * Builds standard 200 HTML response. |
| 302 | * |
| 303 | * @param string $html Rendered HTML content. |
| 304 | * @return ResponseInterface PSR-7 response. |
| 305 | */ |
| 306 | private function htmlResponse(string $html): ResponseInterface |
| 307 | { |
| 308 | $response = $this->psr17->createResponse(200) |
| 309 | ->withHeader('Content-Type', self::HTML_CONTENT_TYPE); |
| 310 | $response->getBody()->write($html); |
| 311 | |
| 312 | return $response; |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Extracts query parameters safely from request. |
| 317 | * |
| 318 | * @param ServerRequestInterface $request PSR-7 request. |
| 319 | * @return array<string, mixed> Resolved query parameters. |
| 320 | */ |
| 321 | private function resolveQueryParams(ServerRequestInterface $request): array |
| 322 | { |
| 323 | $params = $request->getQueryParams(); |
| 324 | if (empty($params) && $request->getUri()->getQuery() !== '') { |
| 325 | parse_str($request->getUri()->getQuery(), $params); |
| 326 | } |
| 327 | |
| 328 | return $params; |
| 329 | } |
| 330 | |
| 331 | /** |
| 332 | * Prepopulates rounded default start/end dates for the calendar module. |
| 333 | * |
| 334 | * @param array<string, mixed> $initialRecord |
| 335 | */ |
| 336 | private function resolveInitialCalendarDates(array &$initialRecord): void |
| 337 | { |
| 338 | if (empty($initialRecord['start_date'])) { |
| 339 | $now = time(); |
| 340 | $rounded = (int) (ceil($now / 900) * 900); |
| 341 | $initialRecord['start_date'] = date(self::DATETIME_FORMAT, $rounded); |
| 342 | $initialRecord['end_date'] = date(self::DATETIME_FORMAT, $rounded + 3600); |
| 343 | return; |
| 344 | } |
| 345 | |
| 346 | if (empty($initialRecord['end_date'])) { |
| 347 | $initialRecord['end_date'] = date( |
| 348 | self::DATETIME_FORMAT, |
| 349 | strtotime((string) $initialRecord['start_date']) + 3600 |
| 350 | ); |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | /** |
| 355 | * Resolves presentation mode from query parameter or module metadata. |
| 356 | * |
| 357 | * @param object|null $moduleObj |
| 358 | * @param array<string, mixed> $queryParams |
| 359 | */ |
| 360 | private function resolvePresentationMode(?object $moduleObj, array $queryParams): string |
| 361 | { |
| 362 | if (isset($queryParams['presentation'])) { |
| 363 | return (string) $queryParams['presentation']; |
| 364 | } |
| 365 | |
| 366 | if ($moduleObj instanceof ModuleMetadata) { |
| 367 | return $moduleObj->createPresentationMode; |
| 368 | } |
| 369 | |
| 370 | return 'drawer'; |
| 371 | } |
| 372 | |
| 373 | /** |
| 374 | * Validates that the record is not system-locked and user has edit rights. |
| 375 | * |
| 376 | * @param string $moduleName Module machine name. |
| 377 | * @param array<string, mixed> $record Record attributes. |
| 378 | * @param PermissionContext $context User permission context. |
| 379 | * @throws PermissionDeniedException If modification is forbidden. |
| 380 | */ |
| 381 | private function assertCanEditRecord(string $moduleName, array $record, PermissionContext $context): void |
| 382 | { |
| 383 | $isSystem = (bool) ($record['is_system'] ?? false); |
| 384 | if ($isSystem) { |
| 385 | throw new PermissionDeniedException( |
| 386 | sprintf('System records in module "%s" are read-only and cannot be modified.', $moduleName) |
| 387 | ); |
| 388 | } |
| 389 | |
| 390 | if (in_array($moduleName, ['system_filters', 'system_filters_grid'], true) && !$context->isSuperuser) { |
| 391 | $owner = isset($record['owner']) ? (int) $record['owner'] : 0; |
| 392 | $coOwners = \App\Shared\Utils\JsonArrayHelper::toIntList($record['co_owners'] ?? []); |
| 393 | $recStatus = isset($record['record_status']) ? (int) $record['record_status'] : 0; |
| 394 | $canEdit = $owner === $context->actorUserId |
| 395 | || in_array($context->actorUserId, $coOwners, true) |
| 396 | || $recStatus >= RecordStatus::ALL_WRITE; |
| 397 | |
| 398 | if (!$canEdit) { |
| 399 | throw new PermissionDeniedException( |
| 400 | 'Only the filter owner or co-owner can modify this filter.' |
| 401 | ); |
| 402 | } |
| 403 | } |
| 404 | } |
| 405 | } |