Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
81.22% |
346 / 426 |
|
65.00% |
26 / 40 |
CRAP | |
0.00% |
0 / 1 |
| UniversalCrudService | |
81.41% |
346 / 425 |
|
65.00% |
26 / 40 |
381.50 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| getMetadataRepository | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getSystemLevelResolver | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getGuard | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getPermissionGuard | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getPersistenceManager | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getProfileRepository | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDataProviderRegistry | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| list | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
| read | |
86.67% |
26 / 30 |
|
0.00% |
0 / 1 |
8.15 | |||
| getNavigation | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| schema | |
75.76% |
25 / 33 |
|
0.00% |
0 / 1 |
8.91 | |||
| create | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| prepareCreatePayload | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
10 | |||
| handlePostCreateSideEffects | |
50.00% |
9 / 18 |
|
0.00% |
0 / 1 |
48.00 | |||
| update | |
83.33% |
30 / 36 |
|
0.00% |
0 / 1 |
12.67 | |||
| saveInventoryItemsIfApplicable | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
4.84 | |||
| resolveMatrixPayload | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
42 | |||
| copyPicklistValues | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
2 | |||
| delete | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
8 | |||
| updateRecordStatus | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
| updateSpecialAccess | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| transformInput | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| getTimeline | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| listCalendarEvents | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| listCalendarUsers | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| fetchDynamicList | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| synchronizePolymorphicColumns | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| listRelatedCalendarEvents | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| listRelatedWorkTimeRecords | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| processIntegrationLifecycle | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| autoGeocodeRecordData | |
85.00% |
17 / 20 |
|
0.00% |
0 / 1 |
14.66 | |||
| assertNotSystemRecord | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| assertRecordReadOwnership | |
96.55% |
28 / 29 |
|
0.00% |
0 / 1 |
10 | |||
| enrichUpdatePayload | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
13.03 | |||
| filterHiddenReadFields | |
28.57% |
2 / 7 |
|
0.00% |
0 / 1 |
19.12 | |||
| filterVisibleFieldsByProfile | |
18.18% |
2 / 11 |
|
0.00% |
0 / 1 |
12.76 | |||
| sanitizeWriteFields | |
33.33% |
3 / 9 |
|
0.00% |
0 / 1 |
21.52 | |||
| resolveRecordStatus | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| isOwnerOrCoOwner | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Engine\Application\Service; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Engine\Application\Navigation\RecordNavigationServiceInterface; |
| 12 | use App\Core\Engine\Application\Persistence\UniversalPersistenceManager; |
| 13 | use App\Core\Engine\Application\Query\Clause\UniversalFilterClauseBuilder; |
| 14 | use App\Core\Engine\Application\Query\UniversalQueryBuilder; |
| 15 | use App\Core\Engine\Application\Security\PermissionGuard; |
| 16 | use App\Core\Engine\Application\Transformer\PolymorphicRelationTransformer; |
| 17 | use App\Core\Engine\Application\Transformer\UiTypeTransformerPipeline; |
| 18 | use App\Core\Engine\Application\Validator\UniversalValidationEngine; |
| 19 | use App\Core\Engine\Domain\DataSource\DynamicDataProviderRegistry; |
| 20 | use App\Core\Engine\Domain\Event\RecordCreatedEvent; |
| 21 | use App\Core\Engine\Domain\Event\RecordDeletedEvent; |
| 22 | use App\Core\Engine\Domain\Event\RecordReadEvent; |
| 23 | use App\Core\Engine\Domain\Event\RecordUpdatedEvent; |
| 24 | use App\Core\Engine\Domain\Exception\PermissionDeniedException; |
| 25 | use App\Core\Engine\Domain\Exception\RecordNotFoundException; |
| 26 | use App\Core\Engine\Domain\Exception\ValidationException; |
| 27 | use App\Core\Engine\Domain\Model\FieldMetadata; |
| 28 | use App\Core\Engine\Domain\Model\ModuleMetadata; |
| 29 | use App\Core\Engine\Domain\Model\PermissionContext; |
| 30 | use App\Core\Engine\Domain\Model\RecordNavigationResult; |
| 31 | use App\Core\Engine\Domain\Model\RecordStatus; |
| 32 | use App\Core\Engine\Domain\Model\SpecialAccess; |
| 33 | use App\Core\Engine\Domain\Repository\AuditRepositoryInterface; |
| 34 | use App\Core\Engine\Domain\Repository\InventoryRecordRepositoryInterface; |
| 35 | use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface; |
| 36 | use App\Core\Engine\Domain\Service\WorkflowEngineInterface; |
| 37 | use App\Core\Engine\Infrastructure\Repository\SqlInventoryRecordRepository; |
| 38 | use App\Core\Grid\GridRequest; |
| 39 | use App\Core\Grid\GridResult; |
| 40 | use App\Modules\Map\Application\Service\MapGeocodingService; |
| 41 | use App\Modules\Profiles\Application\Service\ProfilePermissionService; |
| 42 | use App\Modules\Profiles\Domain\Model\FieldPermissionType; |
| 43 | use App\Modules\Profiles\Domain\Repository\ProfilePermissionRepositoryInterface; |
| 44 | use App\Modules\Structure\Domain\Exception\StructureReassignRequiredException; |
| 45 | use App\Shared\Utils\JsonArrayHelper; |
| 46 | use PDO; |
| 47 | use Psr\EventDispatcher\EventDispatcherInterface; |
| 48 | use 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 | */ |
| 64 | final 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 | } |