PermissionService::USERS_IMPORT, 'departments' => PermissionService::DEPARTMENTS_IMPORT, ]; /** * @param array $profiles */ public function __construct( private readonly CsvReaderService $csvReaderService, private readonly ImportTempFileService $importTempFileService, private readonly ImportAuditService $importAuditService, private readonly ImportStateStoreService $importStateStoreService, private readonly array $profiles, private readonly PermissionService $permissionService, private readonly SettingsDefaultsGateway $settingsDefaultsGateway, private readonly TenantScopeService $userScopeGateway, private readonly SessionStoreInterface $sessionStore ) { } /** * @return array */ public function profiles(): array { return $this->profiles; } public function profile(string $type): ?ImportProfileInterface { $type = trim($type); if ($type === '') { return null; } return $this->profiles[$type] ?? null; } /** * @return array */ public function listProfileOptions(): array { $options = []; foreach ($this->profiles() as $profile) { $options[] = [ 'key' => $profile->key(), 'label' => $profile->label(), ]; } return $options; } public function requiredPermissionForType(string $type): string { $type = trim($type); if ($type === '') { return ''; } return self::PROFILE_PERMISSION_MAP[$type] ?? ''; } /** * @param array $upload * @return array */ public function analyzeUpload(array $upload, string $type, int $currentUserId): array { $profile = $this->profile($type); if (!$profile) { return ['ok' => false, 'code' => 'invalid_file_type']; } $stored = $this->importTempFileService->storeUploadedFile($upload); if (!$stored['ok']) { $code = isset($stored['code']) ? (string) $stored['code'] : 'upload_missing'; return ['ok' => false, 'code' => $code]; } $path = (string) ($stored['path'] ?? ''); $analysis = $this->csvReaderService->analyze($path, self::MAX_ROWS); if (!$analysis['ok']) { $this->importTempFileService->delete($path); $code = isset($analysis['code']) ? (string) $analysis['code'] : 'invalid_csv_header'; return ['ok' => false, 'code' => $code]; } $headers = $analysis['headers'] ?? []; $delimiter = (string) ($analysis['delimiter'] ?? ','); $rowsTotal = (int) ($analysis['rows_total'] ?? 0); $token = $this->newToken(); $this->importStateStoreService->setState($token, [ 'token' => $token, 'type' => $profile->key(), 'path' => $path, 'source_filename' => trim((string) ($upload['name'] ?? '')), 'delimiter' => $delimiter, 'headers' => $headers, 'rows_total' => $rowsTotal, 'user_id' => $currentUserId, 'created_at' => time(), ]); return [ 'ok' => true, 'token' => $token, 'type' => $profile->key(), 'headers' => $headers, 'rows_total' => $rowsTotal, 'delimiter' => $delimiter, 'allowed_targets' => $profile->allowedTargets(), 'required_targets' => $profile->requiredTargets(), 'auto_mapping' => $this->buildAutoMapping($headers, $profile), ]; } /** * @param array $mappingInput * @return array */ public function preview(string $token, array $mappingInput, int $currentUserId): array { $state = $this->state($token); if (!$state) { return ['ok' => false, 'code' => 'invalid_file_type']; } // Prevent cross-user token reuse — the state token is session-scoped but user_id is verified explicitly. if ((int) ($state['user_id'] ?? 0) !== $currentUserId) { return ['ok' => false, 'code' => 'invalid_file_type']; } $profile = $this->profile((string) ($state['type'] ?? '')); if (!$profile) { return ['ok' => false, 'code' => 'invalid_file_type']; } $mappingResult = $this->normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile); if (!($mappingResult['ok'] ?? false)) { return ['ok' => false, 'code' => (string) ($mappingResult['code'] ?? 'mapping_required_missing')]; } $mappedTargets = $mappingResult['mapped_targets'] ?? []; // Tenant/role/department column mapping requires a separate permission — a user who can import // users generally may not be allowed to also assign them to tenants. if ( $profile->requiresAssignmentPermission() && $this->usesAssignmentTargets($mappedTargets) && !$this->canImportAssignments($currentUserId) ) { return ['ok' => false, 'code' => 'assignment_permission_required']; } return $this->process($state, $mappingResult['mapping'], $profile, $currentUserId, false); } /** * @param array $mappingInput * @return array */ public function commit(string $token, array $mappingInput, int $currentUserId): array { $state = $this->state($token); if (!$state) { return ['ok' => false, 'code' => 'invalid_file_type']; } if ((int) ($state['user_id'] ?? 0) !== $currentUserId) { return ['ok' => false, 'code' => 'invalid_file_type']; } $profile = $this->profile((string) ($state['type'] ?? '')); if (!$profile) { return ['ok' => false, 'code' => 'invalid_file_type']; } $mappingResult = $this->normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile); if (!($mappingResult['ok'] ?? false)) { return ['ok' => false, 'code' => (string) ($mappingResult['code'] ?? 'mapping_required_missing')]; } $mappedTargets = $mappingResult['mapped_targets'] ?? []; if ( $profile->requiresAssignmentPermission() && $this->usesAssignmentTargets($mappedTargets) && !$this->canImportAssignments($currentUserId) ) { return ['ok' => false, 'code' => 'assignment_permission_required']; } $auditRunId = $this->importAuditService->startRun( (string) ($state['type'] ?? $profile->key()), $mappingResult['mapped_targets'] ?? [], isset($state['source_filename']) ? (string) $state['source_filename'] : null, $currentUserId, $this->currentTenantId() ); $result = ['ok' => false, 'code' => 'unexpected_error', 'processed' => 0, 'created' => 0, 'skipped' => 0, 'failed' => 1]; $forcedAuditStatus = null; try { $result = $this->process($state, $mappingResult['mapping'], $profile, $currentUserId, true); } catch (\Throwable $exception) { $forcedAuditStatus = 'failed'; $result = ['ok' => false, 'code' => 'unexpected_error', 'processed' => 0, 'created' => 0, 'skipped' => 0, 'failed' => 1]; } finally { $this->importAuditService->finishRun($auditRunId, $result, $forcedAuditStatus); } $this->importTempFileService->delete((string) ($state['path'] ?? '')); $this->importStateStoreService->clearState($token); return $result; } /** * @return array|null */ public function state(string $token): ?array { return $this->importStateStoreService->getState($token); } /** * @param array $state * @param array $mapping * @return array */ private function process( array $state, array $mapping, ImportProfileInterface $profile, int $currentUserId, bool $commit ): array { $path = (string) ($state['path'] ?? ''); $delimiter = (string) ($state['delimiter'] ?? ','); $headers = $state['headers'] ?? []; if (!is_array($headers) || !$headers) { return ['ok' => false, 'code' => 'invalid_csv_header']; } $context = $this->buildContext($currentUserId); $seenRowKeys = []; $processed = 0; $created = 0; $skipped = 0; $failed = 0; $wouldCreate = 0; $errors = []; $errorCounts = []; $this->csvReaderService->streamRows( $path, $delimiter, array_values($headers), function (array $rowAssoc, int $lineNumber) use ( $profile, $mapping, $context, $commit, &$seenRowKeys, &$processed, &$created, &$skipped, &$failed, &$wouldCreate, &$errors, &$errorCounts ): void { $processed++; $mapped = []; $identifier = ''; try { $mapped = $this->mapRow($rowAssoc, $mapping); $validation = $profile->validateMappedRow($mapped, $context); if (!$validation['ok']) { $failed++; foreach ($validation['errors'] as $error) { $this->incrementErrorCount($errorCounts, (string) $error['code']); $this->pushError( $errors, $lineNumber, $profile->rowIdentifier($mapped), (string) $error['code'], isset($error['message']) ? (string) $error['message'] : null ); } return; } $normalized = $validation['normalized']; $identifier = $profile->rowIdentifier($normalized); // Detect duplicates within the file itself (e.g. same email appearing twice). $duplicateKey = $profile->inFileDuplicateKey($normalized); if ($duplicateKey !== null && $duplicateKey !== '') { if (isset($seenRowKeys[$duplicateKey])) { $skipped++; $this->incrementErrorCount($errorCounts, 'duplicate_in_file'); $this->pushError($errors, $lineNumber, $identifier, 'duplicate_in_file', null); return; } $seenRowKeys[$duplicateKey] = true; } $result = $commit ? $profile->commitRow($normalized, $context) : $profile->dryRunRow($normalized, $context); $status = (string) $result['status']; $code = isset($result['code']) ? (string) $result['code'] : ''; $message = isset($result['message']) ? (string) $result['message'] : null; if ($status === 'created') { $created++; return; } if ($status === 'would_create') { $wouldCreate++; return; } if ($status === 'skipped') { $skipped++; $errorCode = $code !== '' ? $code : 'email_exists'; $this->incrementErrorCount($errorCounts, $errorCode); $this->pushError($errors, $lineNumber, $identifier, $errorCode, $message); return; } $failed++; $errorCode = $code !== '' ? $code : 'unexpected_error'; $this->incrementErrorCount($errorCounts, $errorCode); $this->pushError($errors, $lineNumber, $identifier, $errorCode, $message); } catch (\Throwable $exception) { $failed++; $this->incrementErrorCount($errorCounts, 'unexpected_error'); if ($identifier === '' && $mapped) { try { $identifier = $profile->rowIdentifier($mapped); } catch (\Throwable $innerException) { $identifier = ''; } } $this->pushError($errors, $lineNumber, $identifier, 'unexpected_error', null); } } ); if ($commit) { return [ 'ok' => true, 'processed' => $processed, 'created' => $created, 'skipped' => $skipped, 'failed' => $failed, 'error_counts' => $errorCounts, 'errors' => $errors, ]; } return [ 'ok' => true, 'rows_total' => $processed, 'valid_rows' => $wouldCreate + $skipped, 'would_create' => $wouldCreate, 'would_skip' => $skipped, 'would_fail' => $failed, 'errors' => $errors, ]; } /** * @param array $headers * @return array */ private function normalizeMapping(array $mappingInput, array $headers, ImportProfileInterface $profile): array { $mappingRaw = $mappingInput['mapping'] ?? $mappingInput; if (!is_array($mappingRaw)) { $mappingRaw = []; } $allowedTargets = array_keys($profile->allowedTargets()); $allowedMap = array_fill_keys($allowedTargets, true); $requiredTargets = $profile->requiredTargets(); $requiredMap = array_fill_keys($requiredTargets, true); $mapping = []; $mappedTargets = []; foreach ($headers as $header) { $target = trim((string) ($mappingRaw[$header] ?? '')); if ($target === '' || !isset($allowedMap[$target])) { continue; } if (isset($mappedTargets[$target])) { continue; } $mapping[$header] = $target; $mappedTargets[$target] = true; } foreach ($requiredMap as $target => $_) { if (!isset($mappedTargets[$target])) { return ['ok' => false, 'code' => 'mapping_required_missing']; } } return [ 'ok' => true, 'mapping' => $mapping, 'mapped_targets' => array_keys($mappedTargets), ]; } /** * @param array $mapping * @param array $rowAssoc * @return array */ private function mapRow(array $rowAssoc, array $mapping): array { $mapped = []; foreach ($mapping as $header => $target) { if (isset($mapped[$target])) { continue; } $mapped[$target] = trim((string) ($rowAssoc[$header] ?? '')); } return $mapped; } /** * @param array $headers * @return array */ private function buildAutoMapping(array $headers, ImportProfileInterface $profile): array { $mapping = []; $aliases = $profile->autoMapAliases(); $normalizedAliasMap = []; foreach ($aliases as $target => $targetAliases) { $normalizedAliasMap[$this->normalizeHeader($target)] = $target; foreach ($targetAliases as $alias) { $normalizedAliasMap[$this->normalizeHeader($alias)] = $target; } } $assignedTargets = []; foreach ($headers as $header) { $normalized = $this->normalizeHeader($header); if ($normalized === '') { continue; } $target = $normalizedAliasMap[$normalized] ?? ''; if ($target === '' || isset($assignedTargets[$target])) { continue; } $mapping[$header] = $target; $assignedTargets[$target] = true; } return $mapping; } /** * @param array $mappedTargets */ private function usesAssignmentTargets(array $mappedTargets): bool { foreach ($mappedTargets as $target) { if (in_array($target, ['tenant', 'role', 'department'], true)) { return true; } } return false; } private function canImportAssignments(int $userId): bool { return $this->permissionService->userHas($userId, PermissionService::USERS_IMPORT_ASSIGNMENTS); } /** * @return array */ private function buildContext(int $currentUserId): array { $isGlobalAdmin = $this->userScopeGateway->hasGlobalAccess($currentUserId); return [ 'current_user_id' => $currentUserId, 'is_global_admin' => $isGlobalAdmin, // Empty allowed_tenant_ids signals "no restriction" to profile validators for global admins. 'allowed_tenant_ids' => $isGlobalAdmin ? [] : $this->userScopeGateway->getUserTenantIds($currentUserId), 'default_tenant_id' => $this->settingsDefaultsGateway->getDefaultTenantId(), 'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(), 'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(), 'can_import_assignments' => $this->canImportAssignments($currentUserId), ]; } private function currentTenantId(): ?int { $tenant = $this->sessionStore->get('current_tenant', []); if (!is_array($tenant)) { return null; } $tenantId = (int) ($tenant['id'] ?? 0); return $tenantId > 0 ? $tenantId : null; } // Fallback to uniqid if random_bytes is unavailable — not cryptographically ideal but only // used for session-scoped state tokens, not for security-critical values. private function newToken(): string { try { return bin2hex(random_bytes(24)); } catch (\Throwable $exception) { return str_replace('.', '', uniqid('import_', true)); } } private function normalizeHeader(string $value): string { $value = trim($value); if ($value === '') { return ''; } $value = $this->lower($value); $value = str_replace(['-', '.', ' '], '_', $value); $value = preg_replace('/[^a-z0-9_]/', '', $value) ?? ''; $value = preg_replace('/_+/', '_', $value) ?? ''; return trim($value, '_'); } /** * @param array $errors */ // Cap the error list to avoid oversized responses — error_counts still reflects the full count. private function pushError(array &$errors, int $lineNumber, string $identifier, string $code, ?string $message): void { if (count($errors) >= self::MAX_ERROR_ROWS) { return; } $errors[] = [ 'row_number' => $lineNumber, 'identifier' => $identifier, 'code' => $code, 'message' => $message !== null && $message !== '' ? $message : $this->defaultMessageForCode($code), ]; } /** * @param array $errorCounts */ private function incrementErrorCount(array &$errorCounts, string $code): void { $code = trim($code); if ($code === '') { return; } $errorCounts[$code] = (int) ($errorCounts[$code] ?? 0) + 1; } private function defaultMessageForCode(string $code): string { $map = [ 'upload_missing' => $this->translate('No upload file provided'), 'upload_too_large' => $this->translate('Upload exceeds the maximum size'), 'invalid_file_type' => $this->translate('Invalid file type'), 'invalid_csv_header' => $this->translate('CSV header is invalid'), 'empty_csv' => $this->translate('CSV file has no data rows'), 'max_rows_exceeded' => $this->translate('CSV row limit exceeded'), 'mapping_required_missing' => $this->translate('Required mapping is missing'), 'assignment_permission_required' => $this->translate('Assignment import permission is required'), 'invalid_email' => $this->translate('Email is invalid'), 'duplicate_in_file' => $this->translate('Duplicate entry in CSV file'), 'email_exists' => $this->translate('Email already exists'), 'invalid_locale' => $this->translate('Locale is invalid'), 'invalid_active' => $this->translate('Active value is invalid'), 'invalid_hire_date' => $this->translate('Hire date must be YYYY-MM-DD'), 'invalid_tenant_uuid' => $this->translate('Tenant must be a valid UUID'), 'tenant_not_found' => $this->translate('Tenant is missing, inactive or invalid'), 'role_not_found' => $this->translate('Role is missing, inactive or invalid'), 'department_not_found' => $this->translate('Department is missing, inactive or invalid'), 'department_tenant_mismatch' => $this->translate('Department does not belong to tenant'), 'assignment_out_of_scope' => $this->translate('Tenant assignment is out of your scope'), 'code_exists' => $this->translate('Code already exists'), 'department_exists' => $this->translate('Department already exists for this tenant'), 'create_failed' => $this->translate('Entry could not be created'), 'unexpected_error' => $this->translate('Unexpected error during import'), ]; return $map[$code] ?? $this->translate('Unexpected error during import'); } public function messageForCode(string $code): string { return $this->defaultMessageForCode(trim($code)); } private function lower(string $value): string { if (function_exists('mb_strtolower')) { return mb_strtolower($value, 'UTF-8'); } return strtolower($value); } }