Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.36% covered (warning)
89.36%
252 / 282
65.22% covered (warning)
65.22%
15 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProfilePermissionService
89.32% covered (warning)
89.32%
251 / 281
65.22% covered (warning)
65.22%
15 / 23
75.96
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
 clearCompiledMatrixCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getProfile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildPermissionMatrix
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
5
 buildUserPermissionMatrix
61.54% covered (warning)
61.54%
16 / 26
0.00% covered (danger)
0.00%
0 / 1
4.91
 fetchUserData
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
5.31
 resolveProfileDetails
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 buildSuperuserMatrix
80.85% covered (warning)
80.85%
38 / 47
0.00% covered (danger)
0.00%
0 / 1
7.34
 isClientModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateModulePermission
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
5.10
 bulkUpdateModulePermission
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
9.37
 updateFieldPermission
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 bulkUpdateFieldPermissions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 savePermissionMatrix
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
7
 canViewModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canCreateInModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canEditInModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canDeleteInModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFieldPermission
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildFieldItems
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 fetchAllFieldsGroupedByModule
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 executeFieldMetadataQuery
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 buildFieldItemsFromBatch
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
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\Modules\Profiles\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\FieldMetadata;
12use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
13use App\Modules\Profiles\Domain\Model\FieldPermissionType;
14use App\Modules\Profiles\Domain\Model\PermissionProfile;
15use App\Modules\Profiles\Domain\Model\ProfileModulePermission;
16use App\Modules\Profiles\Domain\Repository\ProfilePermissionRepositoryInterface;
17
18/**
19 * Profile Permission Application Service.
20 *
21 * Coordinates functional security across modules and fields, preparing composite data matrices
22 * and applying bulk permission updates.
23 *
24 * @package App\Modules\Profiles\Application\Service
25 */
26final class ProfilePermissionService implements ProfilePermissionServiceInterface
27{
28    /**
29     * Modules belonging to the end-user client operational scope (app-client).
30     *
31     * @var list<string>
32     */
33    public const array CLIENT_MODULES = [
34        'dashboard',
35        'calendar',
36        'work_time',
37        'documents',
38        'contacts',
39        'companies',
40        'partners',
41        'opportunities',
42        'quotes',
43        'orders',
44        'projects',
45        'project_stages',
46        'project_tasks',
47        'tickets',
48        'contracts',
49        'products',
50        'services',
51        'sold_products',
52        'sold_services',
53        'emails',
54        'comments',
55        'accounts',
56    ];
57
58    /**
59     * In-memory cache for compiled profile permission matrices.
60     *
61     * @var array<int, array<string, mixed>>
62     */
63    private static array $compiledMatrixCache = [];
64
65    /**
66     * In-memory cache for compiled superuser permission matrix.
67     *
68     * @var list<array<string, mixed>>|null
69     */
70    private static ?array $compiledSuperuserMatrix = null;
71
72    /**
73     * ProfilePermissionService constructor.
74     *
75     * @param ProfilePermissionRepositoryInterface $profileRepo Repository for profile permissions.
76     * @param MetadataRepositoryInterface          $metadata    System metadata repository.
77     * @param \PDO|null                            $pdo         Optional active database connection.
78     */
79    public function __construct(
80        private readonly ProfilePermissionRepositoryInterface $profileRepo,
81        private readonly MetadataRepositoryInterface          $metadata,
82        private readonly ?\PDO                                $pdo = null
83    ) {
84    }
85
86    /**
87     * Clears compiled matrix static in-memory cache.
88     */
89    public static function clearCompiledMatrixCache(): void
90    {
91        self::$compiledMatrixCache = [];
92        self::$compiledSuperuserMatrix = null;
93    }
94
95    /**
96     * Retrieves the profile aggregate entity by ID.
97     *
98     * @param int $profileId Profile identifier.
99     * @return PermissionProfile|null Profile entity or null.
100     */
101    public function getProfile(int $profileId): ?PermissionProfile
102    {
103        return $this->profileRepo->getProfile($profileId);
104    }
105
106    /**
107     * Builds complete permission matrix for the profile detail tab.
108     *
109     * @param int $profileId Target profile identifier.
110     * @return array<string, mixed> Hierarchical structure of modules and their fields.
111     */
112    public function buildPermissionMatrix(int $profileId): array
113    {
114        if (isset(self::$compiledMatrixCache[$profileId])) {
115            return self::$compiledMatrixCache[$profileId];
116        }
117
118        $modules = $this->metadata->findAllActiveModules();
119        $modulePerms = $this->profileRepo->getModulePermissions($profileId);
120        $fieldPerms = $this->profileRepo->getFieldPermissions($profileId);
121        $batchFields = $this->fetchAllFieldsGroupedByModule();
122
123        $matrix = [];
124        foreach ($modules as $moduleName => $module) {
125            $mPerm = $modulePerms[$moduleName] ?? new ProfileModulePermission(
126                profileId: $profileId,
127                moduleName: $moduleName,
128                canView: true,
129                canCreate: true,
130                canEdit: true,
131                canDelete: true,
132            );
133
134            if (isset($batchFields[$module->id])) {
135                $fieldItems = $this->buildFieldItemsFromBatch(
136                    $moduleName,
137                    $batchFields[$module->id],
138                    $fieldPerms
139                );
140            } else {
141                $fields = $this->metadata->findFields($module->id);
142                $fieldItems = $this->buildFieldItems($moduleName, $fields, $fieldPerms);
143            }
144
145            $isClient = $this->isClientModule($moduleName);
146            $scope = $isClient ? 'client' : 'admin';
147
148            $matrix[] = [
149                'module_id'    => $module->id,
150                'name'         => $moduleName,
151                'module_name'  => $moduleName,
152                'label'        => $module->label,
153                'icon'         => $module->getIconClass(),
154                'icon_class'   => $module->getIconClass(),
155                'type'         => $module->type,
156                'scope'        => $scope,
157                'is_client'    => $isClient,
158                'is_admin'     => !$isClient,
159                'can_view'     => $mPerm->canView,
160                'can_create'   => $mPerm->canCreate,
161                'can_edit'     => $mPerm->canEdit,
162                'can_delete'   => $mPerm->canDelete,
163                'fields_count' => count($fieldItems),
164                'fields'       => $fieldItems,
165            ];
166        }
167
168        $result = [
169            'profile_id' => $profileId,
170            'modules'    => $matrix,
171        ];
172
173        self::$compiledMatrixCache[$profileId] = $result;
174
175        return $result;
176    }
177
178    /**
179     * {@inheritdoc}
180     */
181    public function buildUserPermissionMatrix(int $userId): array
182    {
183        $userData = $this->fetchUserData($userId);
184        if (!$userData) {
185            return [
186                'user_id'      => $userId,
187                'username'     => 'unknown',
188                'user_display' => 'Unknown User',
189                'is_superuser' => false,
190                'profile_id'   => null,
191                'profile_name' => 'Brak profilu',
192                'modules'      => [],
193            ];
194        }
195
196        $isSuperuser = (bool) ($userData['is_superuser'] ?? 0);
197        $displayName = trim(($userData['first_name'] ?? '') . ' ' . ($userData['last_name'] ?? ''));
198        if ($displayName === '') {
199            $displayName = (string) $userData['username'];
200        }
201
202        $profileId = isset($userData['profile_id']) ? (int) $userData['profile_id'] : null;
203        [$profileName, $matrix] = $this->resolveProfileDetails($profileId, $isSuperuser);
204
205        return [
206            'user_id'      => $userId,
207            'username'     => (string) $userData['username'],
208            'user_display' => $displayName,
209            'is_superuser' => $isSuperuser,
210            'profile_id'   => $profileId,
211            'profile_name' => $profileName,
212            'modules'      => $matrix,
213        ];
214    }
215
216    /**
217     * @return array<string, mixed>|null
218     */
219    private function fetchUserData(int $userId): ?array
220    {
221        if ($this->pdo === null) {
222            return null;
223        }
224
225        foreach (['c_mod_users_records', 'a_mod_users_records'] as $tbl) {
226            try {
227                $stmt = $this->pdo->prepare(
228                    'SELECT `id`, `username`, `first_name`, `last_name`, `is_superuser`, `profile_id` '
229                    . "FROM `{$tbl}` WHERE `id` = :uid LIMIT 1"
230                );
231                $stmt->execute([':uid' => $userId]);
232                $userData = $stmt->fetch(\PDO::FETCH_ASSOC);
233                if ($userData) {
234                    return $userData;
235                }
236            } catch (\Throwable) {
237                // Try next table
238            }
239        }
240
241        return null;
242    }
243
244    /**
245     * @return array{0: string, 1: list<array<string, mixed>>}
246     */
247    private function resolveProfileDetails(?int $profileId, bool $isSuperuser): array
248    {
249        if ($profileId !== null) {
250            $profile = $this->profileRepo->getProfile($profileId);
251            $profileName = $profile !== null ? $profile->name : 'Brak przypisanego profilu';
252            $baseMatrix = $this->buildPermissionMatrix($profileId);
253
254            return [$profileName, $baseMatrix['modules'] ?? []];
255        }
256
257        if ($isSuperuser) {
258            return ['Superadministrator (Full Access)', $this->buildSuperuserMatrix()];
259        }
260
261        return ['Brak przypisanego profilu', []];
262    }
263
264    /**
265     * Builds full access permissions matrix for a superuser.
266     *
267     * @return list<array<string, mixed>> Modules list with full permissions.
268     */
269    private function buildSuperuserMatrix(): array
270    {
271        if (self::$compiledSuperuserMatrix !== null) {
272            return self::$compiledSuperuserMatrix;
273        }
274
275        $modules = $this->metadata->findAllActiveModules();
276        $batchFields = $this->fetchAllFieldsGroupedByModule();
277        $matrix = [];
278
279        foreach ($modules as $moduleName => $module) {
280            $fieldItems = [];
281            if (isset($batchFields[$module->id])) {
282                foreach ($batchFields[$module->id] as $f) {
283                    $fieldItems[] = [
284                        'field_id'   => $f['id'],
285                        'field_key'  => $f['field_key'],
286                        'label'      => $f['label'],
287                        'permission' => FieldPermissionType::EDIT->value,
288                        'is_system'  => $f['is_system'],
289                    ];
290                }
291            } else {
292                $fields = $this->metadata->findFields($module->id);
293                foreach ($fields as $field) {
294                    $fieldItems[] = [
295                        'field_id'   => $field->id,
296                        'field_key'  => $field->fieldKey,
297                        'label'      => $field->label,
298                        'permission' => FieldPermissionType::EDIT->value,
299                        'is_system'  => $field->isSystem,
300                    ];
301                }
302            }
303
304            $isClient = $this->isClientModule($moduleName);
305            $scope = $isClient ? 'client' : 'admin';
306
307            $matrix[] = [
308                'module_id'    => $module->id,
309                'name'         => $moduleName,
310                'module_name'  => $moduleName,
311                'label'        => $module->label,
312                'icon'         => $module->getIconClass(),
313                'icon_class'   => $module->getIconClass(),
314                'type'         => $module->type,
315                'scope'        => $scope,
316                'is_client'    => $isClient,
317                'is_admin'     => !$isClient,
318                'can_view'     => true,
319                'can_create'   => true,
320                'can_edit'     => true,
321                'can_delete'   => true,
322                'fields_count' => count($fieldItems),
323                'fields'       => $fieldItems,
324            ];
325        }
326
327        self::$compiledSuperuserMatrix = $matrix;
328
329        return $matrix;
330    }
331
332    /**
333     * Determines whether a module belongs to the end-user client operational scope.
334     *
335     * @param string $moduleName Machine module name.
336     * @return bool True if client operational module, false if admin/system/config module.
337     */
338    public function isClientModule(string $moduleName): bool
339    {
340        return in_array($moduleName, self::CLIENT_MODULES, true);
341    }
342
343    /**
344     * Updates permission flags for a specific module.
345     *
346     * @param int    $profileId  Profile ID.
347     * @param string $moduleName Module machine name.
348     * @param bool   $canView    View permission flag.
349     * @param bool   $canCreate  Create permission flag.
350     * @param bool   $canEdit    Edit permission flag.
351     * @param bool   $canDelete  Delete permission flag.
352     */
353    public function updateModulePermission(
354        int    $profileId,
355        string $moduleName,
356        bool   $canView,
357        bool   $canCreate,
358        bool   $canEdit,
359        bool   $canDelete
360    ): void {
361        self::clearCompiledMatrixCache();
362
363        if ($canDelete) {
364            $canEdit = true;
365            $canView = true;
366        }
367        if ($canEdit) {
368            $canView = true;
369        }
370        if (!$canView) {
371            $canEdit = false;
372            $canDelete = false;
373        }
374        if (!$canEdit) {
375            $canDelete = false;
376        }
377
378        $this->profileRepo->setModulePermission(
379            $profileId,
380            $moduleName,
381            $canView,
382            $canCreate,
383            $canEdit,
384            $canDelete
385        );
386    }
387
388    /**
389     * Bulk updates a specific action permission across all modules.
390     *
391     * @param int    $profileId Target profile ID.
392     * @param string $action    Target action ('view', 'create', 'edit', 'delete').
393     * @param bool   $value     Permission boolean value.
394     */
395    public function bulkUpdateModulePermission(int $profileId, string $action, bool $value): void
396    {
397        self::clearCompiledMatrixCache();
398
399        $this->profileRepo->bulkSetModulePermission($profileId, $action, $value);
400
401        if ($action === 'delete' && $value) {
402            $this->profileRepo->bulkSetModulePermission($profileId, 'edit', true);
403            $this->profileRepo->bulkSetModulePermission($profileId, 'view', true);
404        } elseif ($action === 'edit' && $value) {
405            $this->profileRepo->bulkSetModulePermission($profileId, 'view', true);
406        } elseif ($action === 'view' && !$value) {
407            $this->profileRepo->bulkSetModulePermission($profileId, 'edit', false);
408            $this->profileRepo->bulkSetModulePermission($profileId, 'delete', false);
409        } elseif ($action === 'edit' && !$value) {
410            $this->profileRepo->bulkSetModulePermission($profileId, 'delete', false);
411        }
412    }
413
414    /**
415     * Updates field-level security permission for a specific field.
416     *
417     * @param int    $profileId  Target profile ID.
418     * @param string $moduleName Target module name.
419     * @param string $fieldKey   Target field key.
420     * @param string $permission Assigned permission ('edit', 'view', 'hide').
421     */
422    public function updateFieldPermission(
423        int    $profileId,
424        string $moduleName,
425        string $fieldKey,
426        string $permission
427    ): void {
428        self::clearCompiledMatrixCache();
429
430        $permType = FieldPermissionType::tryFrom($permission) ?? FieldPermissionType::EDIT;
431        $this->profileRepo->setFieldPermission($profileId, $moduleName, $fieldKey, $permType);
432    }
433
434    /**
435     * Bulk updates all fields of a module to the same permission level.
436     *
437     * @param int    $profileId  Target profile ID.
438     * @param string $moduleName Target module name.
439     * @param string $permission Assigned permission ('edit', 'view', 'hide').
440     */
441    public function bulkUpdateFieldPermissions(
442        int    $profileId,
443        string $moduleName,
444        string $permission
445    ): void {
446        self::clearCompiledMatrixCache();
447
448        $permType = FieldPermissionType::tryFrom($permission) ?? FieldPermissionType::EDIT;
449        $this->profileRepo->bulkSetFieldPermissions($profileId, $moduleName, $permType);
450    }
451
452    /**
453     * Saves entire permission matrix (modules and fields) in an atomic operation.
454     *
455     * @param int                        $profileId Target profile ID.
456     * @param list<array<string, mixed>> $modules   Module permissions list.
457     * @param list<array<string, mixed>> $fields    Field permissions list.
458     */
459    public function savePermissionMatrix(
460        int   $profileId,
461        array $modules,
462        array $fields
463    ): void {
464        self::clearCompiledMatrixCache();
465
466        $sanitizedModules = [];
467        foreach ($modules as $m) {
468            $canView = (bool) ($m['can_view'] ?? false);
469            $canCreate = (bool) ($m['can_create'] ?? false);
470            $canEdit = (bool) ($m['can_edit'] ?? false);
471            $canDelete = (bool) ($m['can_delete'] ?? false);
472
473            if ($canDelete) {
474                $canEdit = true;
475                $canView = true;
476            }
477            if ($canEdit) {
478                $canView = true;
479            }
480            if (!$canView) {
481                $canEdit = false;
482                $canDelete = false;
483            }
484            if (!$canEdit) {
485                $canDelete = false;
486            }
487
488            $sanitizedModules[] = [
489                'module_name' => (string) ($m['module_name'] ?? ''),
490                'can_view'    => $canView,
491                'can_create'  => $canCreate,
492                'can_edit'    => $canEdit,
493                'can_delete'  => $canDelete,
494            ];
495        }
496
497        $sanitizedFields = [];
498        foreach ($fields as $f) {
499            $rawPerm = (string) ($f['permission'] ?? 'edit');
500            $permType = FieldPermissionType::tryFrom($rawPerm) ?? FieldPermissionType::EDIT;
501            $sanitizedFields[] = [
502                'module_name' => (string) ($f['module_name'] ?? ''),
503                'field_key'   => (string) ($f['field_key'] ?? ''),
504                'permission'  => $permType,
505            ];
506        }
507
508        $this->profileRepo->saveMatrix($profileId, $sanitizedModules, $sanitizedFields);
509    }
510
511    /**
512     * Checks if a profile has view access to a module.
513     *
514     * @param int    $profileId  Profile ID.
515     * @param string $moduleName Module name.
516     * @return bool True if permitted.
517     */
518    public function canViewModule(int $profileId, string $moduleName): bool
519    {
520        return $this->profileRepo->canViewModule($profileId, $moduleName);
521    }
522
523    /**
524     * Checks if a profile has create access to a module.
525     *
526     * @param int    $profileId  Profile ID.
527     * @param string $moduleName Module name.
528     * @return bool True if permitted.
529     */
530    public function canCreateInModule(int $profileId, string $moduleName): bool
531    {
532        return $this->profileRepo->canCreateInModule($profileId, $moduleName);
533    }
534
535    /**
536     * Checks if a profile has edit access to a module.
537     *
538     * @param int    $profileId  Profile ID.
539     * @param string $moduleName Module name.
540     * @return bool True if permitted.
541     */
542    public function canEditInModule(int $profileId, string $moduleName): bool
543    {
544        return $this->profileRepo->canEditInModule($profileId, $moduleName);
545    }
546
547    /**
548     * Checks if a profile has delete access to a module.
549     *
550     * @param int    $profileId  Profile ID.
551     * @param string $moduleName Module name.
552     * @return bool True if permitted.
553     */
554    public function canDeleteInModule(int $profileId, string $moduleName): bool
555    {
556        return $this->profileRepo->canDeleteInModule($profileId, $moduleName);
557    }
558
559    /**
560     * Gets field permission type for a field.
561     *
562     * @param int    $profileId  Profile ID.
563     * @param string $moduleName Module name.
564     * @param string $fieldKey   Field key.
565     * @return FieldPermissionType Permission enum.
566     */
567    public function getFieldPermission(int $profileId, string $moduleName, string $fieldKey): FieldPermissionType
568    {
569        return $this->profileRepo->getFieldPermission($profileId, $moduleName, $fieldKey);
570    }
571
572    /**
573     * Builds list of field permission items for a module.
574     *
575     * @param string                        $moduleName Module name.
576     * @param array<int, FieldMetadata>     $fields     List of field metadata.
577     * @param array<string, mixed>          $fieldPerms Pre-loaded field permissions.
578     * @return list<array<string, mixed>> Formatted field items.
579     */
580    private function buildFieldItems(
581        string $moduleName,
582        array  $fields,
583        array  $fieldPerms
584    ): array {
585        $items = [];
586        foreach ($fields as $field) {
587            $compositeKey = $moduleName . '.' . $field->fieldKey;
588            $perm = isset($fieldPerms[$compositeKey])
589                ? $fieldPerms[$compositeKey]->permission->value
590                : FieldPermissionType::EDIT->value;
591
592            $items[] = [
593                'field_key'   => $field->fieldKey,
594                'label'       => $field->label,
595                'uitype_name' => $field->uitypeName,
596                'is_system'   => $field->isSystem,
597                'permission'  => $perm,
598            ];
599        }
600
601        return $items;
602    }
603
604    /**
605     * Fetches all fields grouped by module ID in a single query.
606     *
607     * @return array<int, list<array{id: int, field_key: string, label: string, uitype_name: string, is_system: bool}>>
608     */
609    private function fetchAllFieldsGroupedByModule(): array
610    {
611        $rows = $this->executeFieldMetadataQuery();
612        if ($rows === []) {
613            return [];
614        }
615
616        $grouped = [];
617        foreach ($rows as $row) {
618            $modId = (int) ($row['module_id'] ?? 0);
619            $grouped[$modId][] = [
620                'id'          => (int) ($row['id'] ?? 0),
621                'field_key'   => (string) ($row['field_key'] ?? ''),
622                'label'       => (string) ($row['label'] ?? ''),
623                'uitype_name' => (string) ($row['uitype_name'] ?? 'Text'),
624                'is_system'   => (bool) ($row['is_system'] ?? 0),
625            ];
626        }
627
628        return $grouped;
629    }
630
631    /**
632     * @return list<array<string, mixed>>
633     */
634    private function executeFieldMetadataQuery(): array
635    {
636        if ($this->pdo === null) {
637            return [];
638        }
639
640        try {
641            $stmt = $this->pdo->prepare(
642                'SELECT `f`.`id`, `f`.`module_id`, `f`.`field_key`, `f`.`label`, `f`.`is_system`, '
643                . '`u`.`name` AS `uitype_name` '
644                . 'FROM `a_core_field_records` `f` '
645                . 'JOIN `a_core_uitype_records` `u` ON `u`.`id` = `f`.`uitype_id` '
646                . 'ORDER BY `f`.`sort_order` ASC, `f`.`id` ASC'
647            );
648            $stmt->execute();
649            $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
650
651            return is_array($rows) ? $rows : [];
652        } catch (\Throwable) {
653            return [];
654        }
655    }
656
657    /**
658     * Builds list of field permission items from pre-fetched batch rows.
659     *
660     * @param string                                                                                $moduleName
661     * @param list<array{id: int, field_key: string, label: string, uitype_name: string, is_system: bool}> $fields
662     * @param array<string, mixed>                                                                  $fieldPerms
663     * @return list<array<string, mixed>> Formatted field items.
664     */
665    private function buildFieldItemsFromBatch(
666        string $moduleName,
667        array  $fields,
668        array  $fieldPerms
669    ): array {
670        $items = [];
671        foreach ($fields as $field) {
672            $compositeKey = $moduleName . '.' . $field['field_key'];
673            $perm = isset($fieldPerms[$compositeKey])
674                ? $fieldPerms[$compositeKey]->permission->value
675                : FieldPermissionType::EDIT->value;
676
677            $items[] = [
678                'field_key'   => $field['field_key'],
679                'label'       => $field['label'],
680                'uitype_name' => $field['uitype_name'],
681                'is_system'   => $field['is_system'],
682                'permission'  => $perm,
683            ];
684        }
685
686        return $items;
687    }
688}