Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.16% covered (warning)
81.16%
112 / 138
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecordModuleContextBuilder
81.75% covered (warning)
81.75%
112 / 137
16.67% covered (danger)
16.67%
1 / 6
52.72
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
 buildModuleContext
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
3
 assertModuleAccess
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
33.92
 filterFieldsByPermission
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
37.86
 resolveCanCreate
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 enrichModuleExtensionTabs
96.25% covered (success)
96.25%
77 / 80
0.00% covered (danger)
0.00%
0 / 1
13
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Exception\PermissionDeniedException;
12use App\Core\Engine\Domain\Model\FieldMetadata;
13use App\Core\Engine\Domain\Model\ModuleMetadata;
14use App\Core\Engine\Domain\Model\PermissionContext;
15use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
16use App\Modules\Profiles\Domain\Model\FieldPermissionType;
17use App\Modules\Profiles\Domain\Repository\ProfilePermissionRepositoryInterface;
18
19/**
20 * Builds module context including extension tabs, MM relations, and lookup options for UI rendering.
21 */
22final readonly class RecordModuleContextBuilder
23{
24    public function __construct(
25        private MetadataRepositoryInterface           $metadataRepository,
26        private ?ProfilePermissionRepositoryInterface $profileRepo = null,
27    ) {
28    }
29
30    /**
31     * Builds full context array for a module.
32     *
33     * @param string            $moduleName      Module machine name or numeric ID.
34     * @param PermissionContext $context         Security context.
35     * @param bool              $requireWritable If write access is required.
36     * @return array<string, mixed> Module context payload.
37     */
38    public function buildModuleContext(
39        string $moduleName,
40        PermissionContext $context,
41        bool $requireWritable = false
42    ): array {
43        $meta = $this->metadataRepository;
44        $module = ctype_digit($moduleName)
45            ? $meta->findModuleById((int) $moduleName)
46            : $meta->findModule($moduleName);
47
48        $this->assertModuleAccess($module, $context, $requireWritable);
49
50        $mmRelations = $meta->findMmRelationsBySourceModule($module->id);
51        $module = $this->enrichModuleExtensionTabs($module, $mmRelations);
52
53        $tablePrefix = str_starts_with($module->tableName, 'c_') ? 'c_' : 'a_';
54        $fields = $this->filterFieldsByPermission($meta->findFields($module->id), $module, $context);
55        $canCreate = $this->resolveCanCreate($module, $context);
56
57        return [
58            'module'            => $module,
59            'fields'            => $fields,
60            'sections'          => $meta->findSections($module->id),
61            'mm_relations'      => $mmRelations,
62            'module_options'    => $meta->findAllModuleOptions(),
63            'user_options'      => $meta->findAllUserOptions($tablePrefix),
64            'structure_options' => $meta->findAllStructureOptions($tablePrefix),
65            'picklist_options'  => $meta->findAllPicklistOptions(),
66            'uitype_options'    => $meta->findAllUitypeOptions(),
67            'can_create'        => $canCreate,
68        ];
69    }
70
71    /**
72     * Asserts that current permission context allows accessing the requested module.
73     *
74     * @param ModuleMetadata $module Target module metadata.
75     * @param PermissionContext $context Active security context.
76     * @param bool $requireWritable Whether write access is required.
77     * @throws PermissionDeniedException When access is denied.
78     */
79    private function assertModuleAccess(
80        ModuleMetadata $module,
81        PermissionContext $context,
82        bool $requireWritable
83    ): void {
84        if ($module->requiresSuperuserAccess() && !$context->isSuperuser) {
85            throw PermissionDeniedException::superuserRequired($module->name);
86        }
87
88        if (
89            $this->profileRepo !== null
90            && !$context->isSuperuser
91            && $context->actorProfileId !== null
92            && !$this->profileRepo->canViewModule($context->actorProfileId, $module->name)
93        ) {
94            throw PermissionDeniedException::superuserRequired($module->name);
95        }
96
97        if ($requireWritable && !$module->isWritable()) {
98            throw PermissionDeniedException::superuserRequired($module->name);
99        }
100
101        if (
102            $requireWritable
103            && $this->profileRepo !== null
104            && !$context->isSuperuser
105            && $context->actorProfileId !== null
106            && !$this->profileRepo->canCreateInModule($context->actorProfileId, $module->name)
107        ) {
108            throw PermissionDeniedException::superuserRequired($module->name);
109        }
110    }
111
112    /**
113     * Filters and enriches module fields according to active profile permissions.
114     *
115     * @param array<int, FieldMetadata> $fields Raw fields metadata list.
116     * @param ModuleMetadata $module Target module.
117     * @param PermissionContext $context Security context.
118     * @return array<int, FieldMetadata> Filtered fields list.
119     */
120    private function filterFieldsByPermission(
121        array $fields,
122        ModuleMetadata $module,
123        PermissionContext $context
124    ): array {
125        if ($this->profileRepo === null || $context->isSuperuser || $context->actorProfileId === null) {
126            return $fields;
127        }
128
129        $filteredFields = [];
130        foreach ($fields as $field) {
131            $permission = $this->profileRepo->getFieldPermission(
132                $context->actorProfileId,
133                $module->name,
134                $field->fieldKey
135            );
136            if ($permission === FieldPermissionType::HIDE) {
137                continue;
138            }
139            if ($permission === FieldPermissionType::VIEW) {
140                $field = $field->withReadonly(true);
141            }
142            $filteredFields[] = $field;
143        }
144
145        return $filteredFields;
146    }
147
148    /**
149     * Resolves whether user can create records in module under current profile.
150     *
151     * @param ModuleMetadata $module Module metadata.
152     * @param PermissionContext $context Security context.
153     * @return bool
154     */
155    private function resolveCanCreate(ModuleMetadata $module, PermissionContext $context): bool
156    {
157        if (
158            $this->profileRepo !== null
159            && !$context->isSuperuser
160            && $context->actorProfileId !== null
161        ) {
162            return $this->profileRepo->canCreateInModule($context->actorProfileId, $module->name);
163        }
164
165        return true;
166    }
167
168    /**
169     * Enriches module metadata with MM relations and specialized module extension tabs.
170     *
171     * @param ModuleMetadata       $module      Source module.
172     * @param array<int, mixed>    $mmRelations Active MM relations.
173     * @return ModuleMetadata Enriched module.
174     */
175    private function enrichModuleExtensionTabs(ModuleMetadata $module, array $mmRelations): ModuleMetadata
176    {
177        $mmTabs = [];
178        foreach ($mmRelations as $rel) {
179            $targetName = $rel->targetModuleName ?? '';
180            $icon = (!empty($rel->targetModuleIcon) && $rel->targetModuleIcon !== 'bi bi-handshake')
181                ? $rel->targetModuleIcon
182                : match ($targetName) {
183                    'contacts'  => 'bi bi-person-vcard',
184                    'companies' => 'bi bi-building',
185                    'partners'  => 'bi bi-people',
186                    default     => 'bi bi-folder-symlink',
187                };
188            $mmTabs[] = [
189                'id'                 => 'relation_mm_' . $targetName,
190                'label'              => $rel->label,
191                'category'           => 'app',
192                'icon'               => $icon,
193                'partial'            => 'engine/partials/relation_mm_manager.twig',
194                'has_add'            => false,
195                'is_mm_relation'     => true,
196                'target_module_name' => $targetName,
197                'add_title'          => 'Link ' . $rel->label,
198                'views'              => ['detail'],
199                'limit'              => $rel->recordLimit,
200            ];
201        }
202        if ($mmTabs !== []) {
203            $module = $module->withMergedExtensionTabs($mmTabs);
204        }
205
206        if ($module->name === 'workflows') {
207            $module = $module->withMergedExtensionTabs([[
208                'id'       => 'workflow_canvas',
209                'label'    => 'Diagram Editor (DAG)',
210                'category' => 'app',
211                'icon'     => 'bi bi-diagram-3-fill',
212                'partial'  => 'workflow/partials/workflow_canvas.twig',
213                'has_add'  => false,
214                'views'    => ['detail'],
215            ]]);
216        }
217
218        if (in_array($module->name, ['tickets', 'contacts', 'companies'], true)) {
219            $module = $module->withMergedExtensionTabs([[
220                'id'       => 'ticket_email_preview',
221                'label'    => 'Emails',
222                'category' => 'app',
223                'icon'     => 'bi bi-envelope-paper',
224                'partial'  => 'engine/partials/ticket_linked_email_card.twig',
225                'has_add'  => false,
226                'views'    => ['detail'],
227            ]]);
228        }
229
230        if ($module->name === 'system_profiles') {
231            $module = $module->withMergedExtensionTabs([[
232                'id'       => 'profile_permissions',
233                'label'    => 'Profile Permissions',
234                'category' => 'app',
235                'icon'     => 'bi bi-shield-lock-fill',
236                'partial'  => 'profile/partials/profile_permissions_tab.twig',
237                'has_add'  => false,
238                'views'    => ['detail', 'edit', 'create'],
239            ]]);
240        }
241
242        if ($module->name === 'system_users') {
243            $module = $module->withMergedExtensionTabs([
244                [
245                    'id'        => 'access_matrix',
246                    'label'     => 'Access Permission Matrix',
247                    'category'  => 'app',
248                    'icon'      => 'bi bi-shield-check',
249                    'partial'   => 'users/partials/user_access_matrix_tab.twig',
250                    'has_add'   => false,
251                    'add_title' => '',
252                    'views'     => ['detail'],
253                ],
254                [
255                    'id'        => 'profile_matrix',
256                    'label'     => 'Profile Permission Matrix',
257                    'category'  => 'app',
258                    'icon'      => 'bi bi-shield-lock',
259                    'partial'   => 'users/partials/user_profile_matrix_tab.twig',
260                    'has_add'   => false,
261                    'add_title' => '',
262                    'views'     => ['detail'],
263                ],
264            ]);
265        }
266
267        return $module;
268    }
269}