Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.22% covered (warning)
81.22%
346 / 426
65.00% covered (warning)
65.00%
26 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
UniversalCrudService
81.41% covered (warning)
81.41%
346 / 425
65.00% covered (warning)
65.00%
26 / 40
381.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getMetadataRepository
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSystemLevelResolver
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGuard
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPermissionGuard
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPersistenceManager
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProfileRepository
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDataProviderRegistry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 list
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 read
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
8.15
 getNavigation
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 schema
75.76% covered (warning)
75.76%
25 / 33
0.00% covered (danger)
0.00%
0 / 1
8.91
 create
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 prepareCreatePayload
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
10
 handlePostCreateSideEffects
50.00% covered (danger)
50.00%
9 / 18
0.00% covered (danger)
0.00%
0 / 1
48.00
 update
83.33% covered (warning)
83.33%
30 / 36
0.00% covered (danger)
0.00%
0 / 1
12.67
 saveInventoryItemsIfApplicable
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 resolveMatrixPayload
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 copyPicklistValues
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 delete
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
8
 updateRecordStatus
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 updateSpecialAccess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 transformInput
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getTimeline
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 listCalendarEvents
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 listCalendarUsers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchDynamicList
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 synchronizePolymorphicColumns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listRelatedCalendarEvents
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listRelatedWorkTimeRecords
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 processIntegrationLifecycle
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 autoGeocodeRecordData
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
14.66
 assertNotSystemRecord
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 assertRecordReadOwnership
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
10
 enrichUpdatePayload
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
13.03
 filterHiddenReadFields
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
19.12
 filterVisibleFieldsByProfile
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
12.76
 sanitizeWriteFields
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
21.52
 resolveRecordStatus
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isOwnerOrCoOwner
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
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\Application\Navigation\RecordNavigationServiceInterface;
12use App\Core\Engine\Application\Persistence\UniversalPersistenceManager;
13use App\Core\Engine\Application\Query\Clause\UniversalFilterClauseBuilder;
14use App\Core\Engine\Application\Query\UniversalQueryBuilder;
15use App\Core\Engine\Application\Security\PermissionGuard;
16use App\Core\Engine\Application\Transformer\PolymorphicRelationTransformer;
17use App\Core\Engine\Application\Transformer\UiTypeTransformerPipeline;
18use App\Core\Engine\Application\Validator\UniversalValidationEngine;
19use App\Core\Engine\Domain\DataSource\DynamicDataProviderRegistry;
20use App\Core\Engine\Domain\Event\RecordCreatedEvent;
21use App\Core\Engine\Domain\Event\RecordDeletedEvent;
22use App\Core\Engine\Domain\Event\RecordReadEvent;
23use App\Core\Engine\Domain\Event\RecordUpdatedEvent;
24use App\Core\Engine\Domain\Exception\PermissionDeniedException;
25use App\Core\Engine\Domain\Exception\RecordNotFoundException;
26use App\Core\Engine\Domain\Exception\ValidationException;
27use App\Core\Engine\Domain\Model\FieldMetadata;
28use App\Core\Engine\Domain\Model\ModuleMetadata;
29use App\Core\Engine\Domain\Model\PermissionContext;
30use App\Core\Engine\Domain\Model\RecordNavigationResult;
31use App\Core\Engine\Domain\Model\RecordStatus;
32use App\Core\Engine\Domain\Model\SpecialAccess;
33use App\Core\Engine\Domain\Repository\AuditRepositoryInterface;
34use App\Core\Engine\Domain\Repository\InventoryRecordRepositoryInterface;
35use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
36use App\Core\Engine\Domain\Service\WorkflowEngineInterface;
37use App\Core\Engine\Infrastructure\Repository\SqlInventoryRecordRepository;
38use App\Core\Grid\GridRequest;
39use App\Core\Grid\GridResult;
40use App\Modules\Map\Application\Service\MapGeocodingService;
41use App\Modules\Profiles\Application\Service\ProfilePermissionService;
42use App\Modules\Profiles\Domain\Model\FieldPermissionType;
43use App\Modules\Profiles\Domain\Repository\ProfilePermissionRepositoryInterface;
44use App\Modules\Structure\Domain\Exception\StructureReassignRequiredException;
45use App\Shared\Utils\JsonArrayHelper;
46use PDO;
47use Psr\EventDispatcher\EventDispatcherInterface;
48use Throwable;
49
50/**
51 * Universal CRUD Service.
52 *
53 * Central application service orchestrating all data operations for any module.
54 * Implements the full engine pipeline:
55 * 1. Metadata resolution (ModuleMetadata, FieldMetadata[], FilterMetadata)
56 * 2. Permission check (PermissionGuard)
57 * 3. Query/Persistence (UniversalQueryBuilder / UniversalPersistenceManager)
58 * 4. Input transformation (UiTypeTransformerPipeline)
59 * 5. Validation (UniversalValidationEngine)
60 * 6. Audit event dispatch (PSR-14 EventDispatcher)
61 *
62 * @package App\Core\Engine\Application\Service
63 */
64final readonly class UniversalCrudService implements UniversalCrudServiceInterface // NOSONAR
65{
66    private UniversalRecordEnricher $enricher;
67    private UniversalCalendarService $calendarService;
68    private UniversalPolymorphicRelationService $polymorphicService;
69    private UniversalFieldCleanupService $fieldCleanupService;
70    private UniversalUserStructureService $structureService;
71    private ?InventoryRecordRepositoryInterface $inventoryRecordRepository;
72    private SystemLevelResolverInterface $systemLevelResolver;
73
74    /**
75     * UniversalCrudService constructor.
76     *
77     * @param MetadataRepositoryInterface                $metadata             Metadata repository (with cache).
78     * @param PermissionGuard                            $guard                Central access control.
79     * @param UniversalQueryBuilder                      $queryBuilder         Dynamic SELECT query builder.
80     * @param UniversalPersistenceManager                $persistence          INSERT/UPDATE/DELETE manager.
81     * @param UiTypeTransformerPipeline                  $transformers         UiType transform pipeline.
82     * @param UniversalValidationEngine                  $validator            Field validation engine.
83     * @param EventDispatcherInterface                   $eventDispatcher      PSR-14 event bus.
84     * @param RecordNavigationServiceInterface|null      $navigationService    Navigation resolution service.
85     * @param AuditRepositoryInterface|null              $auditRepository      Audit trail log repository.
86     * @param DynamicDataProviderRegistry|null           $dataProviderRegistry Dynamic data provider registry.
87     * @param PrefixGeneratorService|null                $prefixGenerator      Prefix sequence generator service.
88     * @param WorkflowEngineInterface|null               $workflowEngine       Workflow execution engine.
89     * @param MapGeocodingService|null                   $geocodingService     Geocoding service.
90     * @param ProfilePermissionRepositoryInterface|null  $profileRepo          Profile permissions repository.
91     */
92    public function __construct(
93        private MetadataRepositoryInterface                $metadata,
94        private PermissionGuard                            $guard,
95        private UniversalQueryBuilder                      $queryBuilder,
96        private UniversalPersistenceManager                $persistence,
97        private UiTypeTransformerPipeline                  $transformers,
98        private UniversalValidationEngine                  $validator,
99        private EventDispatcherInterface                   $eventDispatcher,
100        private ?RecordNavigationServiceInterface          $navigationService = null,
101        private ?AuditRepositoryInterface                  $auditRepository = null,
102        private ?DynamicDataProviderRegistry               $dataProviderRegistry = null,
103        private ?PrefixGeneratorService                    $prefixGenerator = null,
104        private ?WorkflowEngineInterface                   $workflowEngine = null,
105        private ?MapGeocodingService                       $geocodingService = null,
106        private ?ProfilePermissionRepositoryInterface       $profileRepo = null,
107        ?UniversalCalendarService                          $calendarService = null,
108        ?UniversalPolymorphicRelationService               $polymorphicService = null,
109        ?UniversalFieldCleanupService                      $fieldCleanupService = null,
110        ?UniversalUserStructureService                     $structureService = null,
111        ?InventoryRecordRepositoryInterface                $inventoryRecordRepository = null,
112        ?SystemLevelResolverInterface                      $systemLevelResolver = null,
113    ) {
114        $this->enricher = new UniversalRecordEnricher($metadata, $transformers);
115        $this->calendarService = $calendarService
116            ?? new UniversalCalendarService($this->metadata, $this->guard, $this->persistence);
117        $this->polymorphicService = $polymorphicService ?? new UniversalPolymorphicRelationService();
118        $this->fieldCleanupService = $fieldCleanupService
119            ?? new UniversalFieldCleanupService($this->persistence);
120        $this->structureService = $structureService ?? new UniversalUserStructureService($this->persistence);
121        $this->inventoryRecordRepository = $inventoryRecordRepository;
122        $this->systemLevelResolver = $systemLevelResolver
123            ?? new SystemLevelResolver($this->persistence->getPdo());
124    }
125
126    /**
127     * Returns the underlying metadata repository.
128     *
129     * @return MetadataRepositoryInterface Metadata repository instance.
130     */
131    public function getMetadataRepository(): MetadataRepositoryInterface
132    {
133        return $this->metadata;
134    }
135
136    /**
137     * Returns the system level resolver instance.
138     *
139     * @return SystemLevelResolverInterface Resolver instance.
140     */
141    public function getSystemLevelResolver(): SystemLevelResolverInterface
142    {
143        return $this->systemLevelResolver;
144    }
145
146    /**
147     * Returns the underlying permission guard.
148     *
149     * @return PermissionGuard Guard instance.
150     */
151    public function getGuard(): PermissionGuard
152    {
153        return $this->guard;
154    }
155
156    /**
157     * Alias for getGuard.
158     *
159     * @return PermissionGuard Guard instance.
160     */
161    public function getPermissionGuard(): PermissionGuard
162    {
163        return $this->getGuard();
164    }
165
166    /**
167     * Returns the persistence manager instance.
168     *
169     * @return UniversalPersistenceManager Persistence manager instance.
170     */
171    public function getPersistenceManager(): UniversalPersistenceManager
172    {
173        return $this->persistence;
174    }
175
176    /**
177     * Returns the profile permissions repository instance, if configured.
178     *
179     * @return ProfilePermissionRepositoryInterface|null Profile permission repository.
180     */
181    public function getProfileRepository(): ?ProfilePermissionRepositoryInterface
182    {
183        return $this->profileRepo;
184    }
185
186    /**
187     * Returns the optional dynamic data provider registry.
188     *
189     * @return DynamicDataProviderRegistry|null Registry instance.
190     */
191    public function getDataProviderRegistry(): ?DynamicDataProviderRegistry
192    {
193        return $this->dataProviderRegistry;
194    }
195
196    /**
197     * Lists module records with pagination, sorting, and optional filter.
198     *
199     * @param string           $moduleName Module machine name.
200     * @param GridRequest      $request    Pagination and sort request.
201     * @param PermissionContext $context   Security context.
202     * @param int|null         $filterId   Optional filter record ID.
203     * @return GridResult Paginated list result with display-transformed values.
204     */
205    public function list(
206        string           $moduleName,
207        GridRequest      $request,
208        PermissionContext $context,
209        ?int             $filterId = null
210    ): GridResult {
211        $module = $this->metadata->findModule($moduleName);
212        $this->guard->assertReadAccess($module, $context);
213
214        if ($module->type === 'dynamic' || $module->tableName === '') {
215            return $this->fetchDynamicList($module, $request, $context, $filterId);
216        }
217
218        $fields      = $this->metadata->findFields($module->id);
219        $fields      = $this->filterVisibleFieldsByProfile($moduleName, $fields, $context);
220        $filter      = $this->metadata->findFilter($module->id, $filterId);
221        $applyOwner  = $this->guard->shouldApplyOwnerScope($module, $context);
222
223        if ($this->systemLevelResolver->isLevelScopedModule($moduleName)) {
224            $effectiveLevel = $request->level ?? SystemLevelResolver::DEFAULT_LEVEL;
225            $resolvedTable  = $this->systemLevelResolver->resolveTableName($module, $effectiveLevel);
226            $module         = $module->withTableName($resolvedTable);
227            $fields         = $this->systemLevelResolver->adaptFieldsForLevel($fields, $effectiveLevel);
228        }
229
230        $result = $this->queryBuilder->buildAndExecute($module, $fields, $filter, $request, $context, $applyOwner);
231
232        return $this->enricher->transformRecords($result, $fields);
233    }
234
235    /**
236     * Reads a single module record by primary key.
237     *
238     * @param string           $moduleName Module machine name.
239     * @param int              $id         Record primary key.
240     * @param PermissionContext $context   Security context.
241     * @return array<string, mixed> Single record data with display-transformed values.
242     */
243    public function read(
244        string            $moduleName,
245        int               $id,
246        PermissionContext $context,
247        ?string           $level = null
248    ): array {
249        $module = $this->metadata->findModule($moduleName);
250        $this->guard->assertReadAccess($module, $context);
251
252        if ($module->type === 'dynamic' || $module->tableName === '') {
253            throw RecordNotFoundException::forId($moduleName, $id);
254        }
255
256        $fields = $this->metadata->findFields($module->id);
257
258        if ($this->systemLevelResolver->isLevelScopedModule($moduleName)) {
259            $effectiveLevel = $level ?? SystemLevelResolver::DEFAULT_LEVEL;
260            $resolvedTable  = $this->systemLevelResolver->resolveTableName($module, $effectiveLevel);
261            $module         = $module->withTableName($resolvedTable);
262            $fields         = $this->systemLevelResolver->adaptFieldsForLevel($fields, $effectiveLevel);
263        }
264
265        $snapshot = $this->persistence->fetchSnapshot($module, $id, $fields);
266
267        $this->assertRecordReadOwnership($module, $context, $snapshot);
268
269        if ($moduleName === 'system_users') {
270            $snapshot['structures'] = $this->structureService->fetchUserStructureIds($id);
271        }
272
273        foreach ($fields as $field) {
274            if ($field->fieldKey === 'is_favorite' || $field->uitypeName === 'favorite') {
275                $favStmt = $this->persistence->getPdo()->prepare(
276                    'SELECT 1 FROM `a_core_record_favorites` '
277                    . 'WHERE `module_name` = :m AND `record_id` = :r AND `user_id` = :u LIMIT 1'
278                );
279                $favStmt->execute([
280                    ':m' => $module->name,
281                    ':r' => $id,
282                    ':u' => $context->actorUserId,
283                ]);
284                $snapshot['is_favorite'] = $favStmt->fetchColumn() !== false;
285                break;
286            }
287        }
288
289        $snapshot = $this->enricher->enrichSingleRecord($snapshot, $fields, $this->persistence->getPdo());
290        $snapshot = $this->filterHiddenReadFields($moduleName, $snapshot, $context);
291
292        $this->eventDispatcher->dispatch(new RecordReadEvent($moduleName, $id, null, $context));
293
294        return $snapshot;
295    }
296
297    /**
298     * Resolves record navigation (previous, next, current position, total) within active list context.
299     *
300     * @param string            $moduleName Module machine name.
301     * @param int               $id         Record primary key.
302     * @param GridRequest       $request    Active list request state.
303     * @param PermissionContext $context    Security context.
304     * @param int|null          $filterId   Optional active filter ID.
305     * @return RecordNavigationResult Navigation resolution result.
306     */
307    public function getNavigation(
308        string            $moduleName,
309        int               $id,
310        GridRequest       $request,
311        PermissionContext $context,
312        ?int              $filterId = null,
313    ): RecordNavigationResult {
314        if ($this->navigationService === null) {
315            return new RecordNavigationResult(null, null, 1, 0, 1, 1);
316        }
317
318        return $this->navigationService->resolveNavigation($moduleName, $id, $request, $context, $filterId);
319    }
320
321    /**
322     * Returns field schema metadata for use in dynamic form building.
323     *
324     * @param string           $moduleName Module machine name.
325     * @param PermissionContext $context   Security context.
326     * @return array<int, array<string, mixed>> Field schema for UI rendering.
327     */
328    public function schema(string $moduleName, PermissionContext $context): array
329    {
330        $module = $this->metadata->findModule($moduleName);
331        $this->guard->assertReadAccess($module, $context);
332
333        $fields = $this->metadata->findFields($module->id);
334        $schema = [];
335
336        foreach ($fields as $field) {
337            if ($field->isSystem) {
338                continue;
339            }
340
341            $isReadonly = $field->isReadonly;
342            if ($this->profileRepo !== null && $context->actorProfileId !== null && !$context->isSuperuser) {
343                $fPerm = $this->profileRepo->getFieldPermission(
344                    $context->actorProfileId,
345                    $moduleName,
346                    $field->fieldKey
347                );
348                if ($fPerm === FieldPermissionType::HIDE) {
349                    continue;
350                }
351                if ($fPerm === FieldPermissionType::VIEW) {
352                    $isReadonly = true;
353                }
354            }
355
356            $schema[] = [
357                'key'             => $field->fieldKey,
358                'label'           => $field->label,
359                'uitype'          => $field->uitypeName,
360                'is_mandatory'    => $field->isMandatory,
361                'is_readonly'     => $isReadonly,
362                'is_sortable'     => $field->isSortable,
363                'is_filterable'   => $field->isFilterable,
364                'is_link'         => $field->isTitleField(),
365                'is_title'        => $field->isTitleField(),
366                'icon_class'      => $field->getIconClass(),
367                'relation_module' => $field->relationModule,
368                'options'         => $field->filterOptions,
369                'placeholder'     => $field->placeholder,
370                'default'         => $field->defaultValue,
371            ];
372        }
373
374        return $schema;
375    }
376
377    /**
378     * Creates a new record in the module table.
379     *
380     * @param string               $moduleName Module machine name.
381     * @param array<string, mixed> $input      HTTP input data payload.
382     * @param PermissionContext     $context    Security context.
383     * @return int The primary key of the newly created record.
384     */
385    public function create(string $moduleName, array $input, PermissionContext $context): int
386    {
387        $module = $this->metadata->findModule($moduleName);
388        $this->guard->assertWriteAccess($module, $context);
389
390        $fields = $this->metadata->findFields($module->id);
391        $data   = $this->transformInput($fields, $input);
392        $data   = $this->sanitizeWriteFields($moduleName, $data, $context);
393        $this->synchronizePolymorphicColumns($moduleName, $data);
394
395        $data = $this->prepareCreatePayload($moduleName, $module, $fields, $data, $input, $context);
396
397        $this->validator->validate($fields, $data, $module->tableName, null);
398
399        $newId = $this->persistence->insert($module, $data, $context);
400
401        $this->handlePostCreateSideEffects($moduleName, $module, $newId, $data, $input, $context);
402
403        $this->eventDispatcher->dispatch(new RecordCreatedEvent($moduleName, $newId, $data, $context));
404
405        return $newId;
406    }
407
408    /**
409     * @param array<int, FieldMetadata> $fields
410     * @param array<string, mixed>     $data
411     * @param array<string, mixed>     $input
412     * @return array<string, mixed>
413     */
414    private function prepareCreatePayload(
415        string $moduleName,
416        ModuleMetadata $module,
417        array $fields,
418        array $data,
419        array $input,
420        PermissionContext $context
421    ): array {
422        if ($moduleName === 'contacts') {
423            $firstName = (string) ($data['first_name'] ?? $input['first_name'] ?? '');
424            $lastName  = (string) ($data['last_name'] ?? $input['last_name'] ?? '');
425            $fn        = trim($firstName . ' ' . $lastName);
426            if ($fn !== '') {
427                $data['formatted_name'] = $fn;
428            }
429        }
430
431        if ($this->workflowEngine !== null) {
432            $data = $this->workflowEngine->executeBeforeHook(
433                $moduleName,
434                'before_create',
435                $data,
436                null,
437                $context
438            );
439        }
440
441        if ($moduleName === 'system_fields') {
442            $this->enricher->enrichFieldRecordData($data);
443        }
444
445        if ($moduleName === 'integrations') {
446            $this->processIntegrationLifecycle($data);
447        }
448
449        if ($this->prefixGenerator !== null) {
450            $data = $this->prefixGenerator->generateForCreate($module, $fields, $data);
451        }
452
453        if ($this->geocodingService !== null && in_array($moduleName, ['companies', 'partners', 'contacts'], true)) {
454            $this->autoGeocodeRecordData($data);
455        }
456
457        if ($moduleName === 'calendar') {
458            $data['status'] = 'planned';
459        }
460
461        return $data;
462    }
463
464    /**
465     * @param array<string, mixed> $data
466     * @param array<string, mixed> $input
467     */
468    private function handlePostCreateSideEffects(
469        string $moduleName,
470        ModuleMetadata $module,
471        int $newId,
472        array $data,
473        array $input,
474        PermissionContext $context
475    ): void {
476        if ($moduleName === 'calendar' && !empty($data['parent_id'])) {
477            $this->calendarService->markParentCalendarEventPostponed((int) $data['parent_id']);
478        }
479
480        if ($moduleName === 'system_users' && (isset($input['structures']) || isset($data['structures']))) {
481            $rawStructs = $input['structures'] ?? $data['structures'];
482            $this->structureService->syncUserStructures($newId, $rawStructs);
483        }
484
485        if ($moduleName === 'system_profiles') {
486            $matrixModules = $this->resolveMatrixPayload($input, 'matrix_modules');
487            $matrixFields  = $this->resolveMatrixPayload($input, 'matrix_fields');
488            if (is_array($matrixModules) && is_array($matrixFields) && $this->profileRepo !== null) {
489                $this->profileRepo->saveMatrix($newId, $matrixModules, $matrixFields);
490                ProfilePermissionService::clearCompiledMatrixCache();
491            } elseif (!empty($input['duplicate_from']) && $this->profileRepo !== null) {
492                $this->profileRepo->copyPermissions((int) $input['duplicate_from'], $newId);
493                ProfilePermissionService::clearCompiledMatrixCache();
494            }
495        }
496
497        if ($moduleName === 'system_picklists' && !empty($input['duplicate_from'])) {
498            $this->copyPicklistValues((int) $input['duplicate_from'], $newId, $context->actorUserId);
499        }
500
501        if (isset($input['items']) && is_array($input['items'])) {
502            $this->saveInventoryItemsIfApplicable($module, $newId, $input['items'], $context->actorUserId);
503        }
504    }
505
506    /**
507     * Updates an existing record in the module table.
508     *
509     * @param string               $moduleName Module machine name.
510     * @param int                  $id         Primary key of the record to update.
511     * @param array<string, mixed> $input      HTTP input data payload.
512     * @param PermissionContext     $context    Security context.
513     */
514    public function update(string $moduleName, int $id, array $input, PermissionContext $context): void
515    {
516        $module   = $this->metadata->findModule($moduleName);
517        $fields   = $this->metadata->findFields($module->id);
518        $snapshot = $this->persistence->fetchSnapshot($module, $id, $fields);
519
520        $this->assertNotSystemRecord($moduleName, $snapshot, 'modified');
521
522        $ownerId   = (int) ($snapshot['owner'] ?? 0);
523        $ownerType = (string) ($snapshot['owner_type'] ?? 'user');
524        $coOwners  = JsonArrayHelper::toIntList($snapshot['co_owners'] ?? null);
525        $recStatus = $this->resolveRecordStatus($snapshot);
526
527        $this->guard->assertWriteAccess($module, $context, $ownerId, $coOwners, $ownerType, 'update', $recStatus);
528
529        $data = $this->transformInput($fields, $input);
530        $data = $this->sanitizeWriteFields($moduleName, $data, $context);
531        $this->synchronizePolymorphicColumns($moduleName, $data, $snapshot);
532
533        $this->enrichUpdatePayload($moduleName, $data, $input, $snapshot);
534
535        if ($this->workflowEngine !== null) {
536            $data = $this->workflowEngine->executeBeforeHook(
537                $moduleName,
538                'before_update',
539                $data,
540                $snapshot,
541                $context
542            );
543        }
544
545        $this->validator->validate($fields, $data, $module->tableName, $id, $snapshot);
546
547        $diff = $this->persistence->update($module, $id, $data, $fields);
548
549        if ($moduleName === 'system_users' && (isset($input['structures']) || isset($data['structures']))) {
550            $rawStructs = $input['structures'] ?? $data['structures'];
551            $this->structureService->syncUserStructures($id, $rawStructs);
552        }
553
554        if ($moduleName === 'system_profiles') {
555            $matrixModules = $this->resolveMatrixPayload($input, 'matrix_modules');
556            $matrixFields  = $this->resolveMatrixPayload($input, 'matrix_fields');
557            if (is_array($matrixModules) && is_array($matrixFields) && $this->profileRepo !== null) {
558                $this->profileRepo->saveMatrix($id, $matrixModules, $matrixFields);
559                ProfilePermissionService::clearCompiledMatrixCache();
560            }
561        }
562
563        if (isset($input['items']) && is_array($input['items'])) {
564            $this->saveInventoryItemsIfApplicable($module, $id, $input['items'], $context->actorUserId);
565        }
566
567        if ($diff !== []) {
568            $this->eventDispatcher->dispatch(new RecordUpdatedEvent($moduleName, $id, $diff, $context));
569        }
570    }
571
572    /**
573     * Persists line items to dynamic inventory table if supported by the module.
574     *
575     * @param ModuleMetadata $module Target module metadata.
576     * @param int $recordId Primary key of the record.
577     * @param array<int, array<string, mixed>> $items Array of line items.
578     * @param int $owner User identifier.
579     */
580    private function saveInventoryItemsIfApplicable(
581        ModuleMetadata $module,
582        int $recordId,
583        array $items,
584        int $owner
585    ): void {
586        $tableName = $module->tableName;
587        if ($tableName === '' || !str_ends_with($tableName, '_records')) {
588            return;
589        }
590
591        $invTable = (string) preg_replace('/_records$/', '_inventory', $tableName);
592        $invRepo = $this->inventoryRecordRepository
593            ?? new SqlInventoryRecordRepository($this->persistence->getPdo(), $this->eventDispatcher);
594
595        try {
596            $invRepo->saveItems($invTable, $recordId, $items, $owner);
597        } catch (\Throwable) {
598            // Module may not have an active inventory table; ignore gracefully
599        }
600    }
601
602    /**
603     * @param array<string, mixed> $input
604     * @param string               $key
605     * @return list<array<string, mixed>>|null
606     */
607    private function resolveMatrixPayload(array $input, string $key): ?array
608    {
609        if (isset($input[$key]) && is_array($input[$key])) {
610            return $input[$key];
611        }
612
613        $jsonKey = $key . '_json';
614        if (!empty($input[$jsonKey]) && is_string($input[$jsonKey])) {
615            $decoded = json_decode($input[$jsonKey], true);
616            if (is_array($decoded)) {
617                return $decoded;
618            }
619        }
620
621        return null;
622    }
623
624    /**
625     * Copies picklist values from source picklist to newly created target picklist.
626     *
627     * @param int $sourceId Source picklist identifier.
628     * @param int $targetId Newly created target picklist identifier.
629     * @param int $actorId  Current actor user identifier.
630     */
631    private function copyPicklistValues(int $sourceId, int $targetId, int $actorId): void
632    {
633        $pdo = $this->persistence->getPdo();
634        $sql = 'INSERT INTO `a_core_picklist_value_records` '
635            . '(`picklist_id`, `value`, `short_code`, `label`, `sort_order`, `color`, `icon_class`, '
636            . '`is_default`, `is_editable`, `is_active`, `is_pending_delete`, `created_at`, `updated_at`, '
637            . '`created_by`, `owner`, `co_owners`) '
638            . 'SELECT :target_id, `value`, `short_code`, `label`, `sort_order`, `color`, `icon_class`, '
639            . '`is_default`, `is_editable`, `is_active`, 0, NOW(), NOW(), :actor_id, :actor_id, `co_owners` '
640            . 'FROM `a_core_picklist_value_records` '
641            . 'WHERE `picklist_id` = :source_id AND `is_pending_delete` = 0';
642
643        $stmt = $pdo->prepare($sql);
644        $stmt->execute([
645            ':target_id' => $targetId,
646            ':source_id' => $sourceId,
647            ':actor_id'  => $actorId,
648        ]);
649    }
650
651    /**
652     * Deletes a record from the module table.
653     *
654     * @param string           $moduleName Module machine name.
655     * @param int              $id         Primary key of the record to delete.
656     * @param PermissionContext $context   Security context.
657     */
658    public function delete(string $moduleName, int $id, PermissionContext $context): void
659    {
660        $module   = $this->metadata->findModule($moduleName);
661        $fields   = $this->metadata->findFields($module->id);
662        $snapshot = $this->persistence->fetchSnapshot($module, $id, $fields);
663
664        $this->assertNotSystemRecord($moduleName, $snapshot, 'deleted');
665
666        $ownerId   = (int) ($snapshot['owner'] ?? 0);
667        $ownerType = (string) ($snapshot['owner_type'] ?? 'user');
668        $coOwners  = JsonArrayHelper::toIntList($snapshot['co_owners'] ?? null);
669        $recStatus = $this->resolveRecordStatus($snapshot);
670
671        $this->guard->assertWriteAccess($module, $context, $ownerId, $coOwners, $ownerType, 'delete', $recStatus);
672
673        if ($moduleName === 'system_structure') {
674            $this->structureService->assertStructureCanBeDeleted($id);
675        } elseif ($moduleName === 'system_users' || $moduleName === 'system_administrators') {
676            $this->structureService->assertUserCanBeDeleted($id);
677        }
678
679        if ($this->workflowEngine !== null) {
680            $this->workflowEngine->executeBeforeHook(
681                $moduleName,
682                'before_delete',
683                $snapshot,
684                $snapshot,
685                $context
686            );
687        }
688
689        $this->persistence->delete($module, $id, $fields);
690
691        if ($moduleName === 'system_fields') {
692            $fieldKey = (string) ($snapshot['field_key'] ?? '');
693            $fieldModId = (int) ($snapshot['module_id'] ?? 0);
694            if ($fieldKey !== '' && $fieldModId > 0) {
695                $this->fieldCleanupService->cleanupFieldReferences($fieldKey, $fieldModId);
696            }
697        }
698
699        $this->eventDispatcher->dispatch(new RecordDeletedEvent($moduleName, $id, $snapshot, $context));
700    }
701
702    /**
703     * Updates record lifecycle status (1=Available, 2=Archived, 3=Deleted).
704     *
705     * @param string            $moduleName Module machine name.
706     * @param int               $id         Record primary key.
707     * @param int               $status     Target status code.
708     * @param PermissionContext $context    Security context.
709     */
710    public function updateRecordStatus(
711        string $moduleName,
712        int $id,
713        int $status,
714        PermissionContext $context
715    ): void {
716        if (!$context->isSuperuser) {
717            throw new PermissionDeniedException(
718                'Only superusers are authorized to modify special access or record lifecycle.'
719            );
720        }
721
722        $module   = $this->metadata->findModule($moduleName);
723        $fields   = $this->metadata->findFields($module->id);
724        $snapshot = $this->persistence->fetchSnapshot($module, $id, $fields);
725
726        $isSystem = (bool) ($snapshot['is_system'] ?? false);
727        if ($isSystem) {
728            throw new PermissionDeniedException(
729                sprintf('System records in module "%s" cannot change status.', $moduleName)
730            );
731        }
732
733        $ownerId  = (int) ($snapshot['owner'] ?? 0);
734        $coOwners = JsonArrayHelper::toIntList($snapshot['co_owners'] ?? null);
735
736        $this->guard->assertWriteAccess($module, $context, $ownerId, $coOwners);
737        $this->persistence->updateRecordStatus($module, $id, $status, $fields);
738
739        $accessCol = isset($snapshot['special_access']) ? 'special_access' : 'record_status';
740        $diff = [$accessCol => ['old' => $snapshot[$accessCol] ?? 0, 'new' => $status]];
741        $this->eventDispatcher->dispatch(new RecordUpdatedEvent($moduleName, $id, $diff, $context));
742    }
743
744    /**
745     * Updates record special access level (alias for updateRecordStatus).
746     *
747     * @param string            $moduleName Module machine name.
748     * @param int               $id         Record primary key.
749     * @param int               $status     Target special access code.
750     * @param PermissionContext $context    Security context.
751     */
752    public function updateSpecialAccess(
753        string $moduleName,
754        int $id,
755        int $status,
756        PermissionContext $context
757    ): void {
758        $this->updateRecordStatus($moduleName, $id, $status, $context);
759    }
760
761    /**
762     * Transforms and filters HTTP input to writable field data.
763     *
764     * @param array<int, FieldMetadata> $fields All field definitions.
765     * @param array<string, mixed>      $input  Raw HTTP input payload.
766     * @return array<string, mixed> Transformed write data.
767     */
768    private function transformInput(array $fields, array $input): array
769    {
770        $data = [];
771
772        foreach ($fields as $field) {
773            if ($field->isSystem || $field->isReadonly) {
774                continue;
775            }
776
777            if (array_key_exists($field->fieldKey, $input)) {
778                $data[$field->fieldKey] = $this->transformers->transformWrite($input[$field->fieldKey], $field);
779            }
780        }
781
782        return $data;
783    }
784
785    /**
786     * Retrieves unified chronological audit timeline events for a record.
787     *
788     * @param string            $moduleName Module machine name.
789     * @param int               $id         Record primary key.
790     * @param PermissionContext $context    Security context.
791     * @return list<array<string, mixed>> Timeline events list (newest first).
792     * @throws PermissionDeniedException When actor lacks read access.
793     */
794    public function getTimeline(
795        string $moduleName,
796        int $id,
797        PermissionContext $context
798    ): array {
799        $module = $this->metadata->findModule($moduleName);
800        $this->guard->assertReadAccess($module, $context);
801
802        if ($this->auditRepository === null) {
803            return [];
804        }
805
806        return $this->auditRepository->getTimeline($moduleName, $id);
807    }
808
809    /**
810     * Fetches calendar events for a module within a given date range.
811     *
812     * @param string            $moduleName Module machine name.
813     * @param string            $start      Start ISO datetime.
814     * @param string            $end        End ISO datetime.
815     * @param PermissionContext $context    Security context.
816     * @param int|null          $filterId   Optional filter record ID.
817     * @param string|null       $userId     Optional target user ID or 'all'.
818     * @return array<int, array<string, mixed>> EventCalendar formatted event records.
819     */
820    public function listCalendarEvents(
821        string            $moduleName,
822        string            $start,
823        string            $end,
824        PermissionContext $context,
825        ?int              $filterId = null,
826        ?string           $userId = null,
827    ): array {
828        return $this->calendarService->listCalendarEvents(
829            $moduleName,
830            $start,
831            $end,
832            $context,
833            $filterId,
834            $userId
835        );
836    }
837
838    /**
839     * Returns a list of users having records in the module within the specified filter scope.
840     *
841     * @param string            $moduleName Module machine name.
842     * @param PermissionContext $context    Security context.
843     * @param int|null          $filterId   Optional active filter ID.
844     * @return array{current_user_id: int, active_filter_id: int|null, users: array<int, array<string, mixed>>}
845     */
846    public function listCalendarUsers(
847        string            $moduleName,
848        PermissionContext $context,
849        ?int              $filterId = null,
850    ): array {
851        return $this->calendarService->listCalendarUsers($moduleName, $context, $filterId);
852    }
853
854    /**
855     * Fetches paginated record list from a registered dynamic data provider.
856     *
857     * @param ModuleMetadata    $module   Module metadata.
858     * @param GridRequest       $request  Grid pagination and filter parameters.
859     * @param PermissionContext $context  Permission context.
860     * @param int|null          $filterId Optional filter ID.
861     * @return GridResult Grid result instance.
862     */
863    private function fetchDynamicList(
864        ModuleMetadata $module,
865        GridRequest $request,
866        PermissionContext $context,
867        ?int $filterId
868    ): GridResult {
869        $provider = $this->dataProviderRegistry?->findProvider($module->name);
870        if ($provider !== null) {
871            $dynamicResult = $provider->fetchList($module, $request, $context, $filterId);
872            $fields = $this->metadata->findFields($module->id);
873            if (!empty($fields)) {
874                return $this->enricher->transformRecords($dynamicResult, $fields);
875            }
876            return $dynamicResult;
877        }
878
879        return new GridResult(
880            rows: [],
881            totalRecords: 0,
882            gridRequest: $request,
883            columns: []
884        );
885    }
886
887    /**
888     * Automatically synchronizes integer foreign key columns from polymorphic reference values.
889     *
890     * @param string               $moduleName Active module name.
891     * @param array<string, mixed> $data       Writable field data to synchronize.
892     * @param array<string, mixed> $snapshot   Existing record snapshot for updates.
893     */
894    private function synchronizePolymorphicColumns(
895        string $moduleName,
896        array &$data,
897        array $snapshot = []
898    ): void {
899        $this->polymorphicService->synchronizePolymorphicColumns($moduleName, $data, $snapshot);
900    }
901
902    /**
903     * Lists calendar events related to a specific record in any module.
904     *
905     * @param string $moduleName Parent module machine name.
906     * @param int    $id         Parent record ID.
907     * @return list<array<string, mixed>> List of related calendar events.
908     */
909    public function listRelatedCalendarEvents(string $moduleName, int $id): array
910    {
911        return $this->calendarService->listRelatedCalendarEvents($moduleName, $id);
912    }
913
914    /**
915     * Lists work time entries related to a specific record with summary metrics.
916     *
917     * @param string $moduleName Parent module machine name.
918     * @param int    $id         Parent record ID.
919     * @return array{
920     *     summary: array{total_hours: float, total_minutes: int, billable_hours: float, count: int},
921     *     items: list<array<string, mixed>>
922     * }
923     */
924    public function listRelatedWorkTimeRecords(string $moduleName, int $id): array
925    {
926        return $this->calendarService->listRelatedWorkTimeRecords($moduleName, $id);
927    }
928
929    /**
930     * Enforces integration configuration lifecycle rules.
931     *
932     * @param array<string, mixed>      $data     Current write data payload.
933     * @param array<string, mixed>|null $snapshot Existing record snapshot (null on create).
934     * @throws ValidationException If active status is set on an unconfigured integration.
935     */
936    private function processIntegrationLifecycle(array &$data, ?array $snapshot = null): void
937    {
938        $rawKey = $data['api_key'] ?? $snapshot['api_key'] ?? '';
939        $apiKey = trim((string) $rawKey);
940        $isConfigured = ($apiKey !== '' && !str_starts_with($apiKey, 'demo_key_'));
941        $data['setup_status'] = $isConfigured ? 'configured' : 'requires_configuration';
942
943        $targetStatus = (string) ($data['status'] ?? $snapshot['status'] ?? 'inactive');
944        if ($targetStatus === 'active' && !$isConfigured) {
945            throw new ValidationException([
946                'status' => 'Cannot activate an integration that requires configuration. ' .
947                    'Please enter a valid API key before activating the service.',
948            ]);
949        }
950    }
951
952    /**
953     * Automatically resolves geographical coordinates for records with address details.
954     *
955     * @param array<string, mixed>      $data     Record field data passed by reference.
956     * @param array<string, mixed>|null $snapshot Optional existing record snapshot on update.
957     */
958    private function autoGeocodeRecordData(array &$data, ?array $snapshot = null): void
959    {
960        $hasCoordsInInput = !empty($data['address_latitude']) && !empty($data['address_longitude']);
961        if ($hasCoordsInInput) {
962            return;
963        }
964
965        $street = trim((string) ($data['address_street'] ?? $snapshot['address_street'] ?? ''));
966        $bldg   = trim((string) ($data['address_building_number'] ?? $snapshot['address_building_number'] ?? ''));
967        $postal = trim((string) ($data['address_postal_code'] ?? $snapshot['address_postal_code'] ?? ''));
968        $city   = trim((string) ($data['address_city'] ?? $snapshot['address_city'] ?? ''));
969
970        if ($street === '' && $city === '') {
971            return;
972        }
973
974        $coordsMissing = empty($snapshot['address_latitude']) || empty($snapshot['address_longitude']);
975        $addressChanged = isset($data['address_street']) || isset($data['address_city'])
976            || isset($data['address_building_number']) || isset($data['address_postal_code']);
977
978        if ($snapshot !== null && !$coordsMissing && !$addressChanged) {
979            return;
980        }
981
982        try {
983            $query = trim("{$street} {$bldg} {$postal} {$city}");
984            $points = $this->geocodingService?->searchAddress($query, 1);
985            if (!empty($points[0])) {
986                $data['address_latitude'] = $points[0]->latitude;
987                $data['address_longitude'] = $points[0]->longitude;
988            }
989        } catch (Throwable) {
990            // Geocoding failure should not block write transaction
991        }
992    }
993
994
995    /**
996     * Asserts that a record is not a system-protected entity.
997     *
998     * @param string                $moduleName Target module.
999     * @param array<string, mixed>  $snapshot   Record data.
1000     * @param string                $action     Attempted operation name ('modified' or 'deleted').
1001     * @throws PermissionDeniedException If the record is protected.
1002     */
1003    private function assertNotSystemRecord(string $moduleName, array $snapshot, string $action = 'modified'): void
1004    {
1005        $isSystem = (bool) ($snapshot['is_system'] ?? false);
1006        if ($isSystem) {
1007            $msg = $action === 'deleted'
1008                ? sprintf('System records in module "%s" cannot be deleted.', $moduleName)
1009                : sprintf('System records in module "%s" are read-only and cannot be modified.', $moduleName);
1010            throw new PermissionDeniedException($msg);
1011        }
1012    }
1013
1014    /**
1015     * Asserts that the actor has read permissions via direct or co-ownership.
1016     *
1017     * @param ModuleMetadata       $module   Target module metadata.
1018     * @param PermissionContext    $context  Security context.
1019     * @param array<string, mixed> $snapshot Record snapshot.
1020     * @throws PermissionDeniedException If access is denied.
1021     */
1022    private function assertRecordReadOwnership(
1023        ModuleMetadata    $module,
1024        PermissionContext $context,
1025        array             $snapshot
1026    ): void {
1027        $recStatus = $this->resolveRecordStatus($snapshot);
1028
1029        if ($recStatus === SpecialAccess::HIDDEN && !$context->isSuperuser) {
1030            throw new PermissionDeniedException(sprintf(
1031                'Record #%d in module "%s" is hidden and accessible only to superusers.',
1032                $snapshot['id'] ?? 0,
1033                $module->name
1034            ));
1035        }
1036
1037        $ownerId   = (int) ($snapshot['owner'] ?? 0);
1038        $ownerType = (string) ($snapshot['owner_type'] ?? 'user');
1039        $coOwners  = JsonArrayHelper::toIntList($snapshot['co_owners'] ?? null);
1040
1041        $isOwnerOrCoOwner = $this->isOwnerOrCoOwner($context, $ownerId, $ownerType, $coOwners);
1042
1043        if (!$context->isSuperuser
1044            && ($recStatus === SpecialAccess::ARCHIVED || $recStatus === SpecialAccess::DELETED)
1045            && !$isOwnerOrCoOwner
1046        ) {
1047            throw new PermissionDeniedException(sprintf(
1048                'Record #%d in module "%s" is archived or deleted and accessible only to its owner or co-owners.',
1049                $snapshot['id'] ?? 0,
1050                $module->name
1051            ));
1052        }
1053
1054        if (!$this->guard->shouldApplyOwnerScope($module, $context)) {
1055            return;
1056        }
1057
1058        if (!$isOwnerOrCoOwner) {
1059            if ($this->guard->hasSharedRecordAccess($module->name, $context, $ownerType, $ownerId, 'read')) {
1060                return;
1061            }
1062            throw new PermissionDeniedException(sprintf(
1063                'Record #%d in module "%s" is not visible to the current actor.',
1064                $snapshot['id'] ?? 0,
1065                $module->name
1066            ));
1067        }
1068    }
1069
1070    /**
1071     * Enriches update payload with contact formatted name, system fields, and integrations.
1072     *
1073     * @param string                $moduleName Target module.
1074     * @param array<string, mixed>  $data       Transformed update data (by reference).
1075     * @param array<string, mixed>  $input      Raw input data.
1076     * @param array<string, mixed>  $snapshot   Original snapshot.
1077     */
1078    private function enrichUpdatePayload(
1079        string $moduleName,
1080        array  &$data,
1081        array  $input,
1082        array  $snapshot
1083    ): void {
1084        if ($moduleName === 'contacts' && (isset($input['first_name']) || isset($input['last_name']))) {
1085            $firstName = (string) ($data['first_name'] ?? $input['first_name'] ?? $snapshot['first_name'] ?? '');
1086            $lastName  = (string) ($data['last_name'] ?? $input['last_name'] ?? $snapshot['last_name'] ?? '');
1087            $fn        = trim($firstName . ' ' . $lastName);
1088            if ($fn !== '') {
1089                $data['formatted_name'] = $fn;
1090            }
1091        }
1092
1093        if ($moduleName === 'system_fields') {
1094            $this->enricher->enrichFieldRecordData($data);
1095        }
1096
1097        if ($moduleName === 'integrations') {
1098            $this->processIntegrationLifecycle($data, $snapshot);
1099        }
1100
1101        if ($this->geocodingService !== null && in_array($moduleName, ['companies', 'partners', 'contacts'], true)) {
1102            $this->autoGeocodeRecordData($data, $snapshot);
1103        }
1104
1105        if ($moduleName === 'calendar' && isset($data['status'])) {
1106            $newStatus = (string) $data['status'];
1107            $oldStatus = (string) ($snapshot['status'] ?? '');
1108            if ($newStatus !== $oldStatus && in_array($newStatus, ['in_progress', 'overdue'], true)) {
1109                $data['status'] = $oldStatus;
1110            }
1111        }
1112    }
1113
1114    /**
1115     * Strips fields with HIDE permission from read results for non-superusers.
1116     *
1117     * @param string            $moduleName Target module machine name.
1118     * @param array<string, mixed> $record  Record snapshot data.
1119     * @param PermissionContext $context    Security permission context.
1120     * @return array<string, mixed> Cleaned record data.
1121     */
1122    private function filterHiddenReadFields(string $moduleName, array $record, PermissionContext $context): array
1123    {
1124        if ($context->isSuperuser || $this->profileRepo === null || $context->actorProfileId === null) {
1125            return $record;
1126        }
1127
1128        foreach (array_keys($record) as $key) {
1129            $fPerm = $this->profileRepo->getFieldPermission($context->actorProfileId, $moduleName, (string) $key);
1130            if ($fPerm === FieldPermissionType::HIDE) {
1131                unset($record[$key]);
1132            }
1133        }
1134
1135        return $record;
1136    }
1137
1138    /**
1139     * Filters out fields marked as HIDE in actor profile permissions.
1140     *
1141     * @param string                    $moduleName Target module machine name.
1142     * @param array<int, FieldMetadata> $fields     Original module fields.
1143     * @param PermissionContext         $context    Security permission context.
1144     * @return array<int, FieldMetadata> Filtered fields.
1145     */
1146    private function filterVisibleFieldsByProfile(
1147        string            $moduleName,
1148        array             $fields,
1149        PermissionContext $context
1150    ): array {
1151        if ($context->isSuperuser || $this->profileRepo === null || $context->actorProfileId === null) {
1152            return $fields;
1153        }
1154
1155        return array_values(array_filter(
1156            $fields,
1157            fn(FieldMetadata $f): bool =>
1158                $this->profileRepo->getFieldPermission(
1159                    $context->actorProfileId,
1160                    $moduleName,
1161                    $f->fieldKey
1162                ) !== FieldPermissionType::HIDE
1163        ));
1164    }
1165
1166    /**
1167     * Sanitizes write data payload by removing fields where actor lacks EDIT permission.
1168     *
1169     * @param string            $moduleName Target module machine name.
1170     * @param array<string, mixed> $data    Input payload data.
1171     * @param PermissionContext $context    Security permission context.
1172     * @return array<string, mixed> Sanitized write payload.
1173     */
1174    private function sanitizeWriteFields(string $moduleName, array $data, PermissionContext $context): array
1175    {
1176        if (!$context->isSuperuser) {
1177            unset($data['special_access'], $data['record_status']);
1178        }
1179
1180        if ($context->isSuperuser || $this->profileRepo === null || $context->actorProfileId === null) {
1181            return $data;
1182        }
1183
1184        foreach (array_keys($data) as $key) {
1185            $fPerm = $this->profileRepo->getFieldPermission($context->actorProfileId, $moduleName, (string) $key);
1186            if ($fPerm !== FieldPermissionType::EDIT) {
1187                unset($data[$key]);
1188            }
1189        }
1190
1191        return $data;
1192    }
1193
1194    /**
1195     * Resolves record access status from snapshot.
1196     *
1197     * @param array<string, mixed> $snapshot
1198     */
1199    private function resolveRecordStatus(array $snapshot): ?int
1200    {
1201        if (isset($snapshot['special_access'])) {
1202            return (int) $snapshot['special_access'];
1203        }
1204        if (isset($snapshot['record_status'])) {
1205            return (int) $snapshot['record_status'];
1206        }
1207        return null;
1208    }
1209
1210    /**
1211     * Checks if current actor is owner or co-owner of the record.
1212     *
1213     * @param list<int> $coOwners
1214     */
1215    private function isOwnerOrCoOwner(
1216        PermissionContext $context,
1217        int $ownerId,
1218        string $ownerType,
1219        array $coOwners
1220    ): bool {
1221        $hasDirect = ($ownerType === 'structure')
1222            ? in_array($ownerId, $context->actorStructureIds, true)
1223            : ($ownerId === $context->actorUserId);
1224        $hasCo = in_array($context->actorUserId, $coOwners, true);
1225        if (!$hasCo && !empty($context->actorStructureIds)) {
1226            $hasCo = !empty(array_intersect($context->actorStructureIds, $coOwners));
1227        }
1228
1229        return $hasDirect || $hasCo;
1230    }
1231}