PermissionService::USERS_IMPORT, 'departments' => PermissionService::DEPARTMENTS_IMPORT, ]; /** * @return array */ public static function profiles(): array { static $profiles = null; if (is_array($profiles)) { return $profiles; } $users = new UserImportProfile(); $departments = new DepartmentImportProfile(); $profiles = [ $users->key() => $users, $departments->key() => $departments, ]; return $profiles; } public static function profile(string $type): ?ImportProfileInterface { $type = trim($type); if ($type === '') { return null; } $profiles = self::profiles(); return $profiles[$type] ?? null; } /** * @return array */ public static function listProfileOptions(): array { $options = []; foreach (self::profiles() as $profile) { $options[] = [ 'key' => $profile->key(), 'label' => $profile->label(), ]; } return $options; } public static function requiredPermissionForType(string $type): string { $type = trim($type); if ($type === '') { return ''; } return self::PROFILE_PERMISSION_MAP[$type] ?? ''; } /** * @param array $upload * @return array */ public static function analyzeUpload(array $upload, string $type, int $currentUserId): array { $profile = self::profile($type); if (!$profile) { return ['ok' => false, 'code' => 'invalid_file_type']; } $stored = 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 = CsvReaderService::analyze($path, self::MAX_ROWS); if (!$analysis['ok']) { 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 = self::newToken(); self::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' => self::buildAutoMapping($headers, $profile), ]; } /** * @param array $mappingInput * @return array */ public static function preview(string $token, array $mappingInput, int $currentUserId): array { $state = self::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 = self::profile((string) ($state['type'] ?? '')); if (!$profile) { return ['ok' => false, 'code' => 'invalid_file_type']; } $mappingResult = self::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() && self::usesAssignmentTargets($mappedTargets) && !self::canImportAssignments($currentUserId) ) { return ['ok' => false, 'code' => 'assignment_permission_required']; } return self::process($state, $mappingResult['mapping'], $profile, $currentUserId, false); } /** * @param array $mappingInput * @return array */ public static function commit(string $token, array $mappingInput, int $currentUserId): array { $state = self::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 = self::profile((string) ($state['type'] ?? '')); if (!$profile) { return ['ok' => false, 'code' => 'invalid_file_type']; } $mappingResult = self::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() && self::usesAssignmentTargets($mappedTargets) && !self::canImportAssignments($currentUserId) ) { return ['ok' => false, 'code' => 'assignment_permission_required']; } $auditRunId = ImportAuditService::startRun( (string) ($state['type'] ?? $profile->key()), $mappingResult['mapped_targets'] ?? [], isset($state['source_filename']) ? (string) $state['source_filename'] : null, $currentUserId, isset($_SESSION['current_tenant']['id']) ? (int) $_SESSION['current_tenant']['id'] : null ); $result = null; $forcedAuditStatus = null; try { $result = self::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 { ImportAuditService::finishRun($auditRunId, is_array($result) ? $result : [], $forcedAuditStatus); } ImportTempFileService::delete((string) ($state['path'] ?? '')); self::clearState($token); return is_array($result) ? $result : ['ok' => false, 'code' => 'unexpected_error']; } /** * @return array|null */ public static function state(string $token): ?array { $token = trim($token); if ($token === '') { return null; } $all = $_SESSION[self::SESSION_KEY] ?? []; if (!is_array($all)) { return null; } $state = $all[$token] ?? null; if (!is_array($state)) { return null; } $createdAt = (int) ($state['created_at'] ?? 0); if ($createdAt > 0 && (time() - $createdAt) > 7200) { ImportTempFileService::delete((string) ($state['path'] ?? '')); self::clearState($token); return null; } return $state; } /** * @param array $state * @param array $mapping * @return array */ private static 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 = self::buildContext($currentUserId); $seenRowKeys = []; $processed = 0; $created = 0; $skipped = 0; $failed = 0; $wouldCreate = 0; $errors = []; $errorCounts = []; 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 = self::mapRow($rowAssoc, $mapping); $validation = $profile->validateMappedRow($mapped, $context); if (!$validation['ok']) { $failed++; foreach ($validation['errors'] as $error) { self::incrementErrorCount($errorCounts, (string) ($error['code'] ?? 'unexpected_error')); self::pushError( $errors, $lineNumber, $profile->rowIdentifier($mapped), (string) $error['code'], isset($error['message']) ? (string) $error['message'] : null ); } return; } $normalized = $validation['normalized']; $identifier = $profile->rowIdentifier($normalized); $duplicateKey = $profile->inFileDuplicateKey($normalized); if ($duplicateKey !== null && $duplicateKey !== '') { if (isset($seenRowKeys[$duplicateKey])) { $skipped++; self::incrementErrorCount($errorCounts, 'duplicate_in_file'); self::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'; self::incrementErrorCount($errorCounts, $errorCode); self::pushError($errors, $lineNumber, $identifier, $errorCode, $message); return; } $failed++; $errorCode = $code !== '' ? $code : 'unexpected_error'; self::incrementErrorCount($errorCounts, $errorCode); self::pushError($errors, $lineNumber, $identifier, $errorCode, $message); } catch (\Throwable $exception) { $failed++; self::incrementErrorCount($errorCounts, 'unexpected_error'); if ($identifier === '' && $mapped) { try { $identifier = $profile->rowIdentifier($mapped); } catch (\Throwable $innerException) { $identifier = ''; } } self::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 static 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 static 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 static function buildAutoMapping(array $headers, ImportProfileInterface $profile): array { $mapping = []; $aliases = $profile->autoMapAliases(); $normalizedAliasMap = []; foreach ($aliases as $target => $targetAliases) { $normalizedAliasMap[self::normalizeHeader($target)] = $target; foreach ($targetAliases as $alias) { $normalizedAliasMap[self::normalizeHeader($alias)] = $target; } } $assignedTargets = []; foreach ($headers as $header) { $normalized = self::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 static function usesAssignmentTargets(array $mappedTargets): bool { foreach ($mappedTargets as $target) { if (in_array($target, ['tenant', 'role', 'department'], true)) { return true; } } return false; } private static function canImportAssignments(int $userId): bool { return PermissionService::userHas($userId, PermissionService::USERS_IMPORT_ASSIGNMENTS); } /** * @return array */ private static function buildContext(int $currentUserId): array { $isGlobalAdmin = TenantScopeService::hasGlobalAccess($currentUserId); return [ 'current_user_id' => $currentUserId, 'is_global_admin' => $isGlobalAdmin, 'allowed_tenant_ids' => $isGlobalAdmin ? [] : TenantScopeService::getUserTenantIds($currentUserId), 'default_tenant_id' => SettingService::getDefaultTenantId(), 'default_role_id' => SettingService::getDefaultRoleId(), 'default_department_id' => SettingService::getDefaultDepartmentId(), 'can_import_assignments' => self::canImportAssignments($currentUserId), ]; } private static function newToken(): string { try { return bin2hex(random_bytes(24)); } catch (\Throwable $exception) { return str_replace('.', '', uniqid('import_', true)); } } /** * @param array $state */ private static function setState(string $token, array $state): void { if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) { $_SESSION[self::SESSION_KEY] = []; } $_SESSION[self::SESSION_KEY][$token] = $state; } private static function clearState(string $token): void { if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) { return; } unset($_SESSION[self::SESSION_KEY][$token]); } private static function normalizeHeader(string $value): string { $value = trim($value); if ($value === '') { return ''; } $value = self::lower($value); $value = str_replace(['-', '.', ' '], '_', $value); $value = preg_replace('/[^a-z0-9_]/', '', $value) ?? ''; $value = preg_replace('/_+/', '_', $value) ?? ''; return trim($value, '_'); } /** * @param array $errors */ private static 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 : self::defaultMessageForCode($code), ]; } /** * @param array $errorCounts */ private static function incrementErrorCount(array &$errorCounts, string $code): void { $code = trim($code); if ($code === '') { return; } $errorCounts[$code] = (int) ($errorCounts[$code] ?? 0) + 1; } private static function defaultMessageForCode(string $code): string { $map = [ 'upload_missing' => t('No upload file provided'), 'upload_too_large' => t('Upload exceeds the maximum size'), 'invalid_file_type' => t('Invalid file type'), 'invalid_csv_header' => t('CSV header is invalid'), 'empty_csv' => t('CSV file has no data rows'), 'max_rows_exceeded' => t('CSV row limit exceeded'), 'mapping_required_missing' => t('Required mapping is missing'), 'assignment_permission_required' => t('Assignment import permission is required'), 'invalid_email' => t('Email is invalid'), 'duplicate_in_file' => t('Duplicate entry in CSV file'), 'email_exists' => t('Email already exists'), 'invalid_locale' => t('Locale is invalid'), 'invalid_active' => t('Active value is invalid'), 'invalid_hire_date' => t('Hire date must be YYYY-MM-DD'), 'invalid_tenant_uuid' => t('Tenant must be a valid UUID'), 'tenant_not_found' => t('Tenant is missing, inactive or invalid'), 'role_not_found' => t('Role is missing, inactive or invalid'), 'department_not_found' => t('Department is missing, inactive or invalid'), 'department_tenant_mismatch' => t('Department does not belong to tenant'), 'assignment_out_of_scope' => t('Tenant assignment is out of your scope'), 'code_exists' => t('Code already exists'), 'department_exists' => t('Department already exists for this tenant'), 'create_failed' => t('Entry could not be created'), 'unexpected_error' => t('Unexpected error during import'), ]; return $map[$code] ?? t('Unexpected error during import'); } public static function messageForCode(string $code): string { return self::defaultMessageForCode(trim($code)); } private static function lower(string $value): string { if (function_exists('mb_strtolower')) { return mb_strtolower($value, 'UTF-8'); } return strtolower($value); } }