|null */ private ?array $allowedLocales = null; public function __construct( private readonly UserImportGateway $gateway, private readonly UserAccountService $userAccountService ) { } public function key(): string { return 'users'; } public function label(): string { return t('Users'); } public function allowedTargets(): array { return [ 'first_name' => t('First name'), 'last_name' => t('Last name'), 'email' => t('Email'), 'job_title' => t('Job title'), 'profile_description' => t('Profile description'), 'phone' => t('Phone'), 'mobile' => t('Mobile'), 'short_dial' => t('Short dial'), 'address' => t('Address'), 'postal_code' => t('Postal code'), 'city' => t('City'), 'region' => t('Region'), 'country' => t('Country'), 'hire_date' => t('Hire date'), 'locale' => t('Language'), 'active' => t('Active'), 'tenant' => t('Tenant'), 'role' => t('Role'), 'department' => t('Department'), ]; } public function requiredTargets(): array { return ['first_name', 'last_name', 'email']; } public function autoMapAliases(): array { return [ 'first_name' => ['first_name', 'firstname', 'vorname', 'given_name'], 'last_name' => ['last_name', 'lastname', 'nachname', 'surname', 'family_name'], 'email' => ['email', 'mail', 'e_mail', 'e-mail'], 'job_title' => ['job_title', 'job', 'title', 'position'], 'profile_description' => ['profile_description', 'description', 'about', 'bio'], 'phone' => ['phone', 'telefon', 'business_phone'], 'mobile' => ['mobile', 'cell', 'mobile_phone', 'handy'], 'short_dial' => ['short_dial', 'extension', 'ext'], 'address' => ['address', 'street', 'strasse', 'anschrift'], 'postal_code' => ['postal_code', 'zip', 'plz'], 'city' => ['city', 'ort'], 'region' => ['region', 'state', 'bundesland'], 'country' => ['country', 'land'], 'hire_date' => ['hire_date', 'entry_date', 'start_date', 'eintrittsdatum'], 'locale' => ['locale', 'language', 'lang', 'sprache'], 'active' => ['active', 'status', 'enabled', 'aktiv'], 'tenant' => ['tenant', 'tenant_uuid', 'tenant_id', 'mandant', 'mandant_uuid', 'mandant_id'], 'role' => ['role', 'role_uuid', 'role_id', 'rolle', 'rolle_uuid', 'rolle_id'], 'department' => ['department', 'department_uuid', 'department_id', 'abteilung', 'abteilung_uuid', 'abteilung_id'], ]; } public function supportsAssignments(): bool { return true; } public function requiresAssignmentPermission(): bool { return true; } public function validateMappedRow(array $mapped, array $context): array { $errors = []; $normalized = []; $normalized['first_name'] = trim((string) ($mapped['first_name'] ?? '')); $normalized['last_name'] = trim((string) ($mapped['last_name'] ?? '')); $normalized['email'] = $this->lower(trim((string) ($mapped['email'] ?? ''))); if ($normalized['first_name'] === '') { $errors[] = ['code' => 'mapping_required_missing', 'message' => t('Required field is empty: first_name')]; } if ($normalized['last_name'] === '') { $errors[] = ['code' => 'mapping_required_missing', 'message' => t('Required field is empty: last_name')]; } if ($normalized['email'] === '' || !filter_var($normalized['email'], FILTER_VALIDATE_EMAIL)) { $errors[] = ['code' => 'invalid_email']; } foreach ([ 'job_title', 'profile_description', 'phone', 'mobile', 'short_dial', 'address', 'postal_code', 'city', 'region', 'country', ] as $field) { $normalized[$field] = trim((string) ($mapped[$field] ?? '')); } $normalized['hire_date'] = trim((string) ($mapped['hire_date'] ?? '')); if ($normalized['hire_date'] !== '' && !$this->isValidDate($normalized['hire_date'])) { $errors[] = ['code' => 'invalid_hire_date']; } $normalized['locale'] = $this->lower(trim((string) ($mapped['locale'] ?? ''))); if ($normalized['locale'] !== '' && !in_array($normalized['locale'], $this->allowedLocales(), true)) { $errors[] = ['code' => 'invalid_locale']; } $activeRaw = trim((string) ($mapped['active'] ?? '')); $active = $this->parseActive($activeRaw); if ($activeRaw !== '' && $active === null) { $errors[] = ['code' => 'invalid_active']; } $normalized['active'] = $active ?? 1; $assignmentResult = $this->resolveAssignments($mapped, $context); if (!$assignmentResult['ok']) { $errors = array_merge($errors, $assignmentResult['errors'] ?? []); } else { $normalized['tenant_id'] = (int) ($assignmentResult['tenant_id'] ?? 0); $normalized['role_id'] = (int) ($assignmentResult['role_id'] ?? 0); $normalized['department_id'] = (int) ($assignmentResult['department_id'] ?? 0); } return [ 'ok' => !$errors, 'normalized' => $normalized, 'errors' => $errors, ]; } public function dryRunRow(array $normalized, array $context): array { $existing = $this->gateway->findUserByEmail((string) ($normalized['email'] ?? '')); if ($existing) { return ['status' => 'skipped', 'code' => 'email_exists']; } return ['status' => 'would_create']; } public function commitRow(array $normalized, array $context): array { $existing = $this->gateway->findUserByEmail((string) ($normalized['email'] ?? '')); if ($existing) { return ['status' => 'skipped', 'code' => 'email_exists']; } $password = $this->generatePassword(); $input = [ 'first_name' => (string) ($normalized['first_name'] ?? ''), 'last_name' => (string) ($normalized['last_name'] ?? ''), 'email' => (string) ($normalized['email'] ?? ''), 'job_title' => (string) ($normalized['job_title'] ?? ''), 'profile_description' => (string) ($normalized['profile_description'] ?? ''), 'phone' => (string) ($normalized['phone'] ?? ''), 'mobile' => (string) ($normalized['mobile'] ?? ''), 'short_dial' => (string) ($normalized['short_dial'] ?? ''), 'address' => (string) ($normalized['address'] ?? ''), 'postal_code' => (string) ($normalized['postal_code'] ?? ''), 'city' => (string) ($normalized['city'] ?? ''), 'region' => (string) ($normalized['region'] ?? ''), 'country' => (string) ($normalized['country'] ?? ''), 'hire_date' => (string) ($normalized['hire_date'] ?? ''), 'locale' => (string) ($normalized['locale'] ?? ''), 'password' => $password, 'password2' => $password, 'tenant_ids' => [(int) ($normalized['tenant_id'] ?? 0)], 'role_ids' => [(int) ($normalized['role_id'] ?? 0)], 'department_ids' => [(int) ($normalized['department_id'] ?? 0)], 'primary_tenant_id' => (int) ($normalized['tenant_id'] ?? 0), ]; if ((int) ($normalized['active'] ?? 1) === 1) { $input['active'] = 1; } else { // The admin create flow expects a present-but-null value for inactive state. $input['active'] = null; } $result = $this->userAccountService->createFromAdmin($input, (int) ($context['current_user_id'] ?? 0)); if (!($result['ok'] ?? false)) { $errors = $result['errors'] ?? []; $message = is_array($errors) && isset($errors[0]) ? (string) $errors[0] : t('User can not be registered'); return ['status' => 'failed', 'code' => 'create_failed', 'message' => $message]; } return [ 'status' => 'created', 'uuid' => (string) ($result['uuid'] ?? ''), ]; } public function inFileDuplicateKey(array $normalized): ?string { $email = $this->lower(trim((string) ($normalized['email'] ?? ''))); return $email !== '' ? 'email:' . $email : null; } public function rowIdentifier(array $normalized): string { return trim((string) ($normalized['email'] ?? '')); } /** * @param array $mapped * @param array $context * @return array{ok:bool, tenant_id?:int, role_id?:int, department_id?:int, errors?:array} */ private function resolveAssignments(array $mapped, array $context): array { $errors = []; $tenantRaw = trim((string) ($mapped['tenant'] ?? '')); $roleRaw = trim((string) ($mapped['role'] ?? '')); $departmentRaw = trim((string) ($mapped['department'] ?? '')); $hasAssignmentInput = $tenantRaw !== '' || $roleRaw !== '' || $departmentRaw !== ''; if ($hasAssignmentInput && empty($context['can_import_assignments'])) { return ['ok' => false, 'errors' => [['code' => 'assignment_permission_required']]]; } $tenantId = 0; $roleId = 0; $departmentId = 0; $departmentTenantId = 0; if ($tenantRaw !== '') { $tenant = $this->resolveTenant($tenantRaw); if (!$tenant) { $errors[] = ['code' => 'tenant_not_found']; } else { $tenantId = (int) ($tenant['id'] ?? 0); } } if ($roleRaw !== '') { $role = $this->resolveRole($roleRaw); if (!$role) { $errors[] = ['code' => 'role_not_found']; } else { $roleId = (int) ($role['id'] ?? 0); } } if ($departmentRaw !== '') { $department = $this->resolveDepartment($departmentRaw); if (!$department) { $errors[] = ['code' => 'department_not_found']; } else { $departmentId = (int) ($department['id'] ?? 0); $departmentTenantId = (int) ($department['tenant_id'] ?? 0); if ($tenantId > 0 && $departmentTenantId > 0 && $tenantId !== $departmentTenantId) { $errors[] = ['code' => 'department_tenant_mismatch']; } elseif ($tenantId <= 0 && $departmentTenantId > 0) { $tenantId = $departmentTenantId; } } } if ($tenantId <= 0) { $tenantId = (int) ($context['default_tenant_id'] ?? 0); if ($tenantId <= 0 || !$this->resolveTenant((string) $tenantId)) { $errors[] = ['code' => 'tenant_not_found']; } } if ($roleId <= 0) { $roleId = (int) ($context['default_role_id'] ?? 0); if ($roleId <= 0 || !$this->resolveRole((string) $roleId)) { $errors[] = ['code' => 'role_not_found']; } } if ($departmentId <= 0) { $departmentId = (int) ($context['default_department_id'] ?? 0); if ($departmentId <= 0) { $errors[] = ['code' => 'department_not_found']; } else { $department = $this->resolveDepartment((string) $departmentId); if (!$department) { $errors[] = ['code' => 'department_not_found']; } else { $departmentTenantId = (int) ($department['tenant_id'] ?? 0); } } } if ($tenantId > 0 && $departmentTenantId > 0 && $tenantId !== $departmentTenantId) { $errors[] = ['code' => 'department_tenant_mismatch']; } if ($tenantId > 0 && empty($context['is_global_admin'])) { $allowedTenantIds = $context['allowed_tenant_ids'] ?? []; if (!in_array($tenantId, $allowedTenantIds, true)) { $errors[] = ['code' => 'assignment_out_of_scope']; } } return [ 'ok' => !$errors, 'tenant_id' => $tenantId, 'role_id' => $roleId, 'department_id' => $departmentId, 'errors' => $errors, ]; } private function resolveTenant(string $value): ?array { $tenant = null; if ($this->isUuid($value)) { $tenant = $this->gateway->findTenantByUuid($value); } elseif (ctype_digit($value)) { $tenant = $this->gateway->findTenantById((int) $value); } if (!$tenant) { return null; } return (($tenant['status'] ?? '') === 'active') ? $tenant : null; } private function resolveRole(string $value): ?array { $role = null; if ($this->isUuid($value)) { $role = $this->gateway->findRoleByUuid($value); } elseif (ctype_digit($value)) { $role = $this->gateway->findRoleById((int) $value); } if (!$role) { return null; } return ((int) ($role['active'] ?? 0) === 1) ? $role : null; } private function resolveDepartment(string $value): ?array { $department = null; if ($this->isUuid($value)) { $department = $this->gateway->findDepartmentByUuid($value); } elseif (ctype_digit($value)) { $department = $this->gateway->findDepartmentById((int) $value); } if (!$department) { return null; } return ((int) ($department['active'] ?? 0) === 1) ? $department : null; } private function isValidDate(string $value): bool { $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value); return $dt && $dt->format('Y-m-d') === $value; } private function parseActive(string $value): ?int { $value = $this->lower(trim($value)); if ($value === '') { return null; } if (in_array($value, ['1', 'true', 'yes', 'active', 'ja', 'aktiv'], true)) { return 1; } if (in_array($value, ['0', 'false', 'no', 'inactive', 'nein', 'inaktiv'], true)) { return 0; } return null; } /** * @return array */ private function allowedLocales(): array { if ($this->allowedLocales !== null) { return $this->allowedLocales; } $locales = defined('APP_LOCALES') && is_array(APP_LOCALES) ? APP_LOCALES : []; $locales = array_values(array_filter(array_map(function ($locale) { return $this->lower(trim((string) $locale)); }, $locales), static fn ($locale) => $locale !== '')); $this->allowedLocales = $locales ?: ['de', 'en']; return $this->allowedLocales; } private function generatePassword(): string { try { return 'imp_' . bin2hex(random_bytes(24)) . 'A!'; } catch (\Throwable $exception) { return 'imp_' . str_replace('.', '', uniqid('', true)) . 'A!'; } } private function isUuid(string $value): bool { return (bool) preg_match('/^[a-f0-9-]{36}$/i', $value); } private function lower(string $value): string { if (function_exists('mb_strtolower')) { return mb_strtolower($value, 'UTF-8'); } return strtolower($value); } }