Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.63% covered (success)
92.63%
176 / 190
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralEngineWebController
93.12% covered (success)
93.12%
176 / 189
40.00% covered (danger)
40.00%
4 / 10
63.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 actionIndex
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
7
 actionCreate
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
17
 actionDetail
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
10
 actionEdit
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
8
 htmlResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resolveQueryParams
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 resolveInitialCalendarDates
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
3.85
 resolvePresentationMode
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 assertCanEditRecord
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
9.53
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\Exception\ModuleNotFoundException;
12use App\Core\Engine\Domain\Exception\PermissionDeniedException;
13use App\Core\Engine\Domain\Exception\RecordNotFoundException;
14use App\Core\Engine\Domain\Model\ModuleMetadata;
15use App\Core\Engine\Domain\Model\PermissionContext;
16use App\Core\Engine\Domain\Model\RecordStatus;
17use App\Core\Engine\Presentation\Api\CentralEngineApiController;
18use App\Core\Engine\Presentation\Web\Support\RecordCreationPrefillResolver;
19use App\Core\Engine\Presentation\Web\Support\RecordTitleResolver;
20use App\Core\Grid\GridRequest;
21use Nyholm\Psr7\Factory\Psr17Factory;
22use Psr\Http\Message\ResponseInterface;
23use Psr\Http\Message\ServerRequestInterface;
24use 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 */
34final 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}