instances added god may help

This commit is contained in:
2026-02-23 12:58:19 +01:00
parent 25370a1a55
commit 99db252f60
290 changed files with 9858 additions and 4914 deletions

View File

@@ -9,14 +9,14 @@ class CsvReaderService
/**
* @return array{ok:bool, delimiter?:string, headers?:array<int,string>, rows_total?:int, code?:string}
*/
public static function analyze(string $path, int $maxRows): array
public function analyze(string $path, int $maxRows): array
{
if (!is_file($path) || !is_readable($path)) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
$delimiter = self::detectDelimiter($path);
$headerMeta = self::readHeader($path, $delimiter);
$delimiter = $this->detectDelimiter($path);
$headerMeta = $this->readHeader($path, $delimiter);
if (!$headerMeta['ok']) {
$code = isset($headerMeta['code']) ? (string) $headerMeta['code'] : 'invalid_csv_header';
return ['ok' => false, 'code' => $code];
@@ -28,7 +28,7 @@ class CsvReaderService
return ['ok' => false, 'code' => 'invalid_csv_header'];
}
$rowsTotal = self::countRows($path, $delimiter, $headers, $headerLine);
$rowsTotal = $this->countRows($path, $delimiter, $headers, $headerLine);
if ($rowsTotal <= 0) {
return ['ok' => false, 'code' => 'empty_csv'];
}
@@ -47,9 +47,9 @@ class CsvReaderService
/**
* @param array<int, string> $headers
*/
public static function streamRows(string $path, string $delimiter, array $headers, callable $callback): void
public function streamRows(string $path, string $delimiter, array $headers, callable $callback): void
{
$headerMeta = self::readHeader($path, $delimiter);
$headerMeta = $this->readHeader($path, $delimiter);
if (!$headerMeta['ok']) {
return;
}
@@ -68,15 +68,15 @@ class CsvReaderService
continue;
}
$row = self::normalizeRowColumns($row, count($headers));
if (self::isEmptyRow($row)) {
$row = $this->normalizeRowColumns($row, count($headers));
if ($this->isEmptyRow($row)) {
continue;
}
$rowIndex++;
$assoc = [];
foreach ($headers as $index => $header) {
$assoc[$header] = self::cleanCell($row[$index] ?? '');
$assoc[$header] = $this->cleanCell($row[$index] ?? '');
}
$callback($assoc, $lineNumber, $rowIndex);
}
@@ -84,7 +84,7 @@ class CsvReaderService
fclose($handle);
}
private static function detectDelimiter(string $path): string
private function detectDelimiter(string $path): string
{
$sample = '';
$handle = @fopen($path, 'rb');
@@ -115,7 +115,7 @@ class CsvReaderService
/**
* @return array{ok:bool, headers?:array<int,string>, header_line?:int, code?:string}
*/
private static function readHeader(string $path, string $delimiter): array
private function readHeader(string $path, string $delimiter): array
{
$handle = @fopen($path, 'rb');
if (!$handle) {
@@ -128,16 +128,16 @@ class CsvReaderService
if (!is_array($row)) {
continue;
}
$row = self::normalizeRowColumns($row, count($row));
if (self::isEmptyRow($row)) {
$row = $this->normalizeRowColumns($row, count($row));
if ($this->isEmptyRow($row)) {
continue;
}
$headers = [];
foreach ($row as $index => $cell) {
$value = self::cleanCell((string) $cell);
$value = $this->cleanCell((string) $cell);
if ($index === 0) {
$value = self::stripBom($value);
$value = $this->stripBom($value);
}
$headers[] = trim($value);
}
@@ -154,7 +154,7 @@ class CsvReaderService
}
}
$normalizedKeys = array_map([self::class, 'lower'], $headers);
$normalizedKeys = array_map([$this, 'lower'], $headers);
if (count(array_unique($normalizedKeys)) !== count($normalizedKeys)) {
return ['ok' => false, 'code' => 'invalid_csv_header'];
}
@@ -170,11 +170,11 @@ class CsvReaderService
* @param array<int, mixed> $row
* @return array<int, string>
*/
private static function normalizeRowColumns(array $row, int $size): array
private function normalizeRowColumns(array $row, int $size): array
{
$normalized = [];
foreach ($row as $cell) {
$normalized[] = self::cleanCell((string) $cell);
$normalized[] = $this->cleanCell((string) $cell);
}
$current = count($normalized);
@@ -190,7 +190,7 @@ class CsvReaderService
/**
* @param array<int, string> $headers
*/
private static function countRows(string $path, string $delimiter, array $headers, int $headerLine): int
private function countRows(string $path, string $delimiter, array $headers, int $headerLine): int
{
$count = 0;
$handle = @fopen($path, 'rb');
@@ -207,8 +207,8 @@ class CsvReaderService
if (!is_array($row)) {
continue;
}
$row = self::normalizeRowColumns($row, count($headers));
if (self::isEmptyRow($row)) {
$row = $this->normalizeRowColumns($row, count($headers));
if ($this->isEmptyRow($row)) {
continue;
}
$count++;
@@ -218,7 +218,7 @@ class CsvReaderService
return $count;
}
private static function isEmptyRow(array $row): bool
private function isEmptyRow(array $row): bool
{
foreach ($row as $cell) {
if (trim((string) $cell) !== '') {
@@ -228,7 +228,7 @@ class CsvReaderService
return true;
}
private static function stripBom(string $value): string
private function stripBom(string $value): string
{
if (str_starts_with($value, "\xEF\xBB\xBF")) {
return substr($value, 3);
@@ -236,14 +236,14 @@ class CsvReaderService
return $value;
}
private static function cleanCell(string $value): string
private function cleanCell(string $value): string
{
$value = str_replace("\0", '', $value);
$value = self::stripBom($value);
$value = $this->stripBom($value);
return trim($value);
}
private static function lower(string $value): string
private function lower(string $value): string
{
if (function_exists('mb_strtolower')) {
return mb_strtolower($value, 'UTF-8');

View File

@@ -3,16 +3,14 @@
namespace MintyPHP\Service\Import;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Audit\ImportAuditService;
use MintyPHP\Service\Import\Profile\DepartmentImportProfile;
use MintyPHP\Service\Import\Profile\ImportProfileInterface;
use MintyPHP\Service\Import\Profile\UserImportProfile;
use MintyPHP\Service\Settings\SettingService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\User\UserScopeGateway;
class ImportService
{
private const SESSION_KEY = 'admin_imports_v1';
private const MAX_ROWS = 20000;
private const MAX_ERROR_ROWS = 500;
private const PROFILE_PERMISSION_MAP = [
@@ -21,42 +19,44 @@ class ImportService
];
/**
* @return array<string, ImportProfileInterface>
* @param array<string, ImportProfileInterface> $profiles
*/
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 function __construct(
private readonly CsvReaderService $csvReaderService,
private readonly ImportTempFileService $importTempFileService,
private readonly ImportAuditService $importAuditService,
private readonly ImportStateStoreService $importStateStoreService,
private readonly array $profiles,
private readonly PermissionGateway $permissionGateway,
private readonly SettingGateway $settingGateway,
private readonly UserScopeGateway $userScopeGateway
) {
}
public static function profile(string $type): ?ImportProfileInterface
/**
* @return array<string, ImportProfileInterface>
*/
public function profiles(): array
{
return $this->profiles;
}
public function profile(string $type): ?ImportProfileInterface
{
$type = trim($type);
if ($type === '') {
return null;
}
$profiles = self::profiles();
return $profiles[$type] ?? null;
return $this->profiles[$type] ?? null;
}
/**
* @return array<int, array{key:string,label:string}>
*/
public static function listProfileOptions(): array
public function listProfileOptions(): array
{
$options = [];
foreach (self::profiles() as $profile) {
foreach ($this->profiles() as $profile) {
$options[] = [
'key' => $profile->key(),
'label' => $profile->label(),
@@ -65,7 +65,7 @@ class ImportService
return $options;
}
public static function requiredPermissionForType(string $type): string
public function requiredPermissionForType(string $type): string
{
$type = trim($type);
if ($type === '') {
@@ -78,23 +78,23 @@ class ImportService
* @param array<string, mixed> $upload
* @return array<string, mixed>
*/
public static function analyzeUpload(array $upload, string $type, int $currentUserId): array
public function analyzeUpload(array $upload, string $type, int $currentUserId): array
{
$profile = self::profile($type);
$profile = $this->profile($type);
if (!$profile) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
$stored = ImportTempFileService::storeUploadedFile($upload);
$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 = CsvReaderService::analyze($path, self::MAX_ROWS);
$analysis = $this->csvReaderService->analyze($path, self::MAX_ROWS);
if (!$analysis['ok']) {
ImportTempFileService::delete($path);
$this->importTempFileService->delete($path);
$code = isset($analysis['code']) ? (string) $analysis['code'] : 'invalid_csv_header';
return ['ok' => false, 'code' => $code];
}
@@ -103,8 +103,8 @@ class ImportService
$delimiter = (string) ($analysis['delimiter'] ?? ',');
$rowsTotal = (int) ($analysis['rows_total'] ?? 0);
$token = self::newToken();
self::setState($token, [
$token = $this->newToken();
$this->importStateStoreService->setState($token, [
'token' => $token,
'type' => $profile->key(),
'path' => $path,
@@ -125,7 +125,7 @@ class ImportService
'delimiter' => $delimiter,
'allowed_targets' => $profile->allowedTargets(),
'required_targets' => $profile->requiredTargets(),
'auto_mapping' => self::buildAutoMapping($headers, $profile),
'auto_mapping' => $this->buildAutoMapping($headers, $profile),
];
}
@@ -133,9 +133,9 @@ class ImportService
* @param array<string, mixed> $mappingInput
* @return array<string, mixed>
*/
public static function preview(string $token, array $mappingInput, int $currentUserId): array
public function preview(string $token, array $mappingInput, int $currentUserId): array
{
$state = self::state($token);
$state = $this->state($token);
if (!$state) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
@@ -143,12 +143,12 @@ class ImportService
return ['ok' => false, 'code' => 'invalid_file_type'];
}
$profile = self::profile((string) ($state['type'] ?? ''));
$profile = $this->profile((string) ($state['type'] ?? ''));
if (!$profile) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
$mappingResult = self::normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile);
$mappingResult = $this->normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile);
if (!($mappingResult['ok'] ?? false)) {
return ['ok' => false, 'code' => (string) ($mappingResult['code'] ?? 'mapping_required_missing')];
}
@@ -156,22 +156,22 @@ class ImportService
$mappedTargets = $mappingResult['mapped_targets'] ?? [];
if (
$profile->requiresAssignmentPermission()
&& self::usesAssignmentTargets($mappedTargets)
&& !self::canImportAssignments($currentUserId)
&& $this->usesAssignmentTargets($mappedTargets)
&& !$this->canImportAssignments($currentUserId)
) {
return ['ok' => false, 'code' => 'assignment_permission_required'];
}
return self::process($state, $mappingResult['mapping'], $profile, $currentUserId, false);
return $this->process($state, $mappingResult['mapping'], $profile, $currentUserId, false);
}
/**
* @param array<string, mixed> $mappingInput
* @return array<string, mixed>
*/
public static function commit(string $token, array $mappingInput, int $currentUserId): array
public function commit(string $token, array $mappingInput, int $currentUserId): array
{
$state = self::state($token);
$state = $this->state($token);
if (!$state) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
@@ -179,12 +179,12 @@ class ImportService
return ['ok' => false, 'code' => 'invalid_file_type'];
}
$profile = self::profile((string) ($state['type'] ?? ''));
$profile = $this->profile((string) ($state['type'] ?? ''));
if (!$profile) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
$mappingResult = self::normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile);
$mappingResult = $this->normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile);
if (!($mappingResult['ok'] ?? false)) {
return ['ok' => false, 'code' => (string) ($mappingResult['code'] ?? 'mapping_required_missing')];
}
@@ -192,13 +192,13 @@ class ImportService
$mappedTargets = $mappingResult['mapped_targets'] ?? [];
if (
$profile->requiresAssignmentPermission()
&& self::usesAssignmentTargets($mappedTargets)
&& !self::canImportAssignments($currentUserId)
&& $this->usesAssignmentTargets($mappedTargets)
&& !$this->canImportAssignments($currentUserId)
) {
return ['ok' => false, 'code' => 'assignment_permission_required'];
}
$auditRunId = ImportAuditService::startRun(
$auditRunId = $this->importAuditService->startRun(
(string) ($state['type'] ?? $profile->key()),
$mappingResult['mapped_targets'] ?? [],
isset($state['source_filename']) ? (string) $state['source_filename'] : null,
@@ -206,51 +206,29 @@ class ImportService
isset($_SESSION['current_tenant']['id']) ? (int) $_SESSION['current_tenant']['id'] : null
);
$result = null;
$result = ['ok' => false, 'code' => 'unexpected_error', 'processed' => 0, 'created' => 0, 'skipped' => 0, 'failed' => 1];
$forcedAuditStatus = null;
try {
$result = self::process($state, $mappingResult['mapping'], $profile, $currentUserId, true);
$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 {
ImportAuditService::finishRun($auditRunId, is_array($result) ? $result : [], $forcedAuditStatus);
$this->importAuditService->finishRun($auditRunId, $result, $forcedAuditStatus);
}
ImportTempFileService::delete((string) ($state['path'] ?? ''));
self::clearState($token);
$this->importTempFileService->delete((string) ($state['path'] ?? ''));
$this->importStateStoreService->clearState($token);
return is_array($result) ? $result : ['ok' => false, 'code' => 'unexpected_error'];
return $result;
}
/**
* @return array<string, mixed>|null
*/
public static function state(string $token): ?array
public 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;
return $this->importStateStoreService->getState($token);
}
/**
@@ -258,7 +236,7 @@ class ImportService
* @param array<string, string> $mapping
* @return array<string, mixed>
*/
private static function process(
private function process(
array $state,
array $mapping,
ImportProfileInterface $profile,
@@ -272,7 +250,7 @@ class ImportService
return ['ok' => false, 'code' => 'invalid_csv_header'];
}
$context = self::buildContext($currentUserId);
$context = $this->buildContext($currentUserId);
$seenRowKeys = [];
$processed = 0;
$created = 0;
@@ -282,7 +260,7 @@ class ImportService
$errors = [];
$errorCounts = [];
CsvReaderService::streamRows(
$this->csvReaderService->streamRows(
$path,
$delimiter,
array_values($headers),
@@ -304,13 +282,13 @@ class ImportService
$mapped = [];
$identifier = '';
try {
$mapped = self::mapRow($rowAssoc, $mapping);
$mapped = $this->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(
$this->incrementErrorCount($errorCounts, (string) $error['code']);
$this->pushError(
$errors,
$lineNumber,
$profile->rowIdentifier($mapped),
@@ -327,8 +305,8 @@ class ImportService
if ($duplicateKey !== null && $duplicateKey !== '') {
if (isset($seenRowKeys[$duplicateKey])) {
$skipped++;
self::incrementErrorCount($errorCounts, 'duplicate_in_file');
self::pushError($errors, $lineNumber, $identifier, 'duplicate_in_file', null);
$this->incrementErrorCount($errorCounts, 'duplicate_in_file');
$this->pushError($errors, $lineNumber, $identifier, 'duplicate_in_file', null);
return;
}
$seenRowKeys[$duplicateKey] = true;
@@ -350,18 +328,18 @@ class ImportService
if ($status === 'skipped') {
$skipped++;
$errorCode = $code !== '' ? $code : 'email_exists';
self::incrementErrorCount($errorCounts, $errorCode);
self::pushError($errors, $lineNumber, $identifier, $errorCode, $message);
$this->incrementErrorCount($errorCounts, $errorCode);
$this->pushError($errors, $lineNumber, $identifier, $errorCode, $message);
return;
}
$failed++;
$errorCode = $code !== '' ? $code : 'unexpected_error';
self::incrementErrorCount($errorCounts, $errorCode);
self::pushError($errors, $lineNumber, $identifier, $errorCode, $message);
$this->incrementErrorCount($errorCounts, $errorCode);
$this->pushError($errors, $lineNumber, $identifier, $errorCode, $message);
} catch (\Throwable $exception) {
$failed++;
self::incrementErrorCount($errorCounts, 'unexpected_error');
$this->incrementErrorCount($errorCounts, 'unexpected_error');
if ($identifier === '' && $mapped) {
try {
$identifier = $profile->rowIdentifier($mapped);
@@ -369,7 +347,7 @@ class ImportService
$identifier = '';
}
}
self::pushError($errors, $lineNumber, $identifier, 'unexpected_error', null);
$this->pushError($errors, $lineNumber, $identifier, 'unexpected_error', null);
}
}
);
@@ -401,7 +379,7 @@ class ImportService
* @param array<int, string> $headers
* @return array<string, mixed>
*/
private static function normalizeMapping(array $mappingInput, array $headers, ImportProfileInterface $profile): array
private function normalizeMapping(array $mappingInput, array $headers, ImportProfileInterface $profile): array
{
$mappingRaw = $mappingInput['mapping'] ?? $mappingInput;
if (!is_array($mappingRaw)) {
@@ -445,7 +423,7 @@ class ImportService
* @param array<string, string> $rowAssoc
* @return array<string, string>
*/
private static function mapRow(array $rowAssoc, array $mapping): array
private function mapRow(array $rowAssoc, array $mapping): array
{
$mapped = [];
foreach ($mapping as $header => $target) {
@@ -461,22 +439,22 @@ class ImportService
* @param array<int, string> $headers
* @return array<string, string>
*/
private static function buildAutoMapping(array $headers, ImportProfileInterface $profile): array
private function buildAutoMapping(array $headers, ImportProfileInterface $profile): array
{
$mapping = [];
$aliases = $profile->autoMapAliases();
$normalizedAliasMap = [];
foreach ($aliases as $target => $targetAliases) {
$normalizedAliasMap[self::normalizeHeader($target)] = $target;
$normalizedAliasMap[$this->normalizeHeader($target)] = $target;
foreach ($targetAliases as $alias) {
$normalizedAliasMap[self::normalizeHeader($alias)] = $target;
$normalizedAliasMap[$this->normalizeHeader($alias)] = $target;
}
}
$assignedTargets = [];
foreach ($headers as $header) {
$normalized = self::normalizeHeader($header);
$normalized = $this->normalizeHeader($header);
if ($normalized === '') {
continue;
}
@@ -494,7 +472,7 @@ class ImportService
/**
* @param array<int, string> $mappedTargets
*/
private static function usesAssignmentTargets(array $mappedTargets): bool
private function usesAssignmentTargets(array $mappedTargets): bool
{
foreach ($mappedTargets as $target) {
if (in_array($target, ['tenant', 'role', 'department'], true)) {
@@ -504,29 +482,29 @@ class ImportService
return false;
}
private static function canImportAssignments(int $userId): bool
private function canImportAssignments(int $userId): bool
{
return PermissionService::userHas($userId, PermissionService::USERS_IMPORT_ASSIGNMENTS);
return $this->permissionGateway->userHas($userId, PermissionService::USERS_IMPORT_ASSIGNMENTS);
}
/**
* @return array<string, mixed>
*/
private static function buildContext(int $currentUserId): array
private function buildContext(int $currentUserId): array
{
$isGlobalAdmin = TenantScopeService::hasGlobalAccess($currentUserId);
$isGlobalAdmin = $this->userScopeGateway->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),
'allowed_tenant_ids' => $isGlobalAdmin ? [] : $this->userScopeGateway->getUserTenantIds($currentUserId),
'default_tenant_id' => $this->settingGateway->getDefaultTenantId(),
'default_role_id' => $this->settingGateway->getDefaultRoleId(),
'default_department_id' => $this->settingGateway->getDefaultDepartmentId(),
'can_import_assignments' => $this->canImportAssignments($currentUserId),
];
}
private static function newToken(): string
private function newToken(): string
{
try {
return bin2hex(random_bytes(24));
@@ -535,33 +513,14 @@ class ImportService
}
}
/**
* @param array<string, mixed> $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
private function normalizeHeader(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
$value = self::lower($value);
$value = $this->lower($value);
$value = str_replace(['-', '.', ' '], '_', $value);
$value = preg_replace('/[^a-z0-9_]/', '', $value) ?? '';
$value = preg_replace('/_+/', '_', $value) ?? '';
@@ -571,7 +530,7 @@ class ImportService
/**
* @param array<int, array{row_number:int,identifier:string,code:string,message:string}> $errors
*/
private static function pushError(array &$errors, int $lineNumber, string $identifier, string $code, ?string $message): void
private function pushError(array &$errors, int $lineNumber, string $identifier, string $code, ?string $message): void
{
if (count($errors) >= self::MAX_ERROR_ROWS) {
return;
@@ -580,14 +539,14 @@ class ImportService
'row_number' => $lineNumber,
'identifier' => $identifier,
'code' => $code,
'message' => $message !== null && $message !== '' ? $message : self::defaultMessageForCode($code),
'message' => $message !== null && $message !== '' ? $message : $this->defaultMessageForCode($code),
];
}
/**
* @param array<string, int> $errorCounts
*/
private static function incrementErrorCount(array &$errorCounts, string $code): void
private function incrementErrorCount(array &$errorCounts, string $code): void
{
$code = trim($code);
if ($code === '') {
@@ -596,47 +555,59 @@ class ImportService
$errorCounts[$code] = (int) ($errorCounts[$code] ?? 0) + 1;
}
private static function defaultMessageForCode(string $code): string
private 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'),
'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] ?? t('Unexpected error during import');
return $map[$code] ?? $this->translate('Unexpected error during import');
}
public static function messageForCode(string $code): string
public function messageForCode(string $code): string
{
return self::defaultMessageForCode(trim($code));
return $this->defaultMessageForCode(trim($code));
}
private static function lower(string $value): string
private function lower(string $value): string
{
if (function_exists('mb_strtolower')) {
return mb_strtolower($value, 'UTF-8');
}
return strtolower($value);
}
private function translate(string $text, mixed ...$args): string
{
if (function_exists('t')) {
return t($text, ...$args);
}
if ($args === []) {
return $text;
}
return vsprintf($text, array_map(static fn ($arg) => (string) $arg, $args));
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace MintyPHP\Service\Import;
use MintyPHP\Repository\Audit\ImportAuditRunRepository;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Audit\ImportAuditService;
use MintyPHP\Service\Import\Profile\DepartmentImportGateway;
use MintyPHP\Service\Import\Profile\DepartmentImportProfile;
use MintyPHP\Service\Import\Profile\UserImportGateway;
use MintyPHP\Service\Import\Profile\UserImportProfile;
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\User\UserServicesFactory;
class ImportServicesFactory
{
private ?AccessServicesFactory $accessServicesFactory = null;
private ?CsvReaderService $csvReaderService = null;
private ?ImportTempFileService $importTempFileService = null;
private ?ImportStateStoreService $importStateStoreService = null;
private ?ImportAuditRunRepository $importAuditRunRepository = null;
private ?ImportAuditService $importAuditService = null;
private ?PermissionGateway $permissionGateway = null;
private ?SettingServicesFactory $settingServicesFactory = null;
private ?SettingGateway $settingGateway = null;
private ?ImportService $importService = null;
private ?UserServicesFactory $userServicesFactory = null;
public function createImportService(): ImportService
{
if ($this->importService !== null) {
return $this->importService;
}
$profiles = [
'users' => new UserImportProfile(new UserImportGateway(
$this->getUserServicesFactory()->createUserReadRepository(),
$this->getUserServicesFactory()->createUserAccountService()
)),
'departments' => new DepartmentImportProfile(new DepartmentImportGateway()),
];
return $this->importService = new ImportService(
$this->getCsvReaderService(),
$this->getImportTempFileService(),
$this->createImportAuditService(),
$this->createImportStateStoreService(),
$profiles,
$this->createPermissionGateway(),
$this->createSettingGateway(),
$this->getUserServicesFactory()->createUserScopeGateway()
);
}
public function createImportAuditService(): ImportAuditService
{
return $this->importAuditService ??= new ImportAuditService($this->getImportAuditRunRepository());
}
public function createImportStateStoreService(): ImportStateStoreService
{
return $this->importStateStoreService ??= new ImportStateStoreService($this->getImportTempFileService());
}
private function getCsvReaderService(): CsvReaderService
{
return $this->csvReaderService ??= new CsvReaderService();
}
private function getImportTempFileService(): ImportTempFileService
{
return $this->importTempFileService ??= new ImportTempFileService();
}
private function getImportAuditRunRepository(): ImportAuditRunRepository
{
return $this->importAuditRunRepository ??= new ImportAuditRunRepository();
}
private function getUserServicesFactory(): UserServicesFactory
{
return $this->userServicesFactory ??= new UserServicesFactory();
}
private function createPermissionGateway(): PermissionGateway
{
return $this->permissionGateway ??= $this->accessServicesFactory()->createPermissionGateway();
}
private function accessServicesFactory(): AccessServicesFactory
{
return $this->accessServicesFactory ??= new AccessServicesFactory();
}
private function createSettingGateway(): SettingGateway
{
return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway();
}
private function settingServicesFactory(): SettingServicesFactory
{
return $this->settingServicesFactory ??= new SettingServicesFactory();
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace MintyPHP\Service\Import;
class ImportStateStoreService
{
private const SESSION_KEY = 'admin_imports_v1';
private const STATE_TTL_SECONDS = 7200;
public function __construct(private readonly ImportTempFileService $importTempFileService)
{
}
/**
* @param array<string, mixed> $state
*/
public 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;
}
/**
* @return array<string, mixed>|null
*/
public function getState(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) > self::STATE_TTL_SECONDS) {
$this->importTempFileService->delete((string) ($state['path'] ?? ''));
$this->clearState($token);
return null;
}
return $state;
}
public function clearState(string $token): void
{
if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
return;
}
unset($_SESSION[self::SESSION_KEY][$token]);
}
}

View File

@@ -12,7 +12,7 @@ class ImportTempFileService
* @param array<string, mixed> $upload
* @return array{ok:bool, path?:string, code?:string}
*/
public static function storeUploadedFile(array $upload): array
public function storeUploadedFile(array $upload): array
{
$tmpName = (string) ($upload['tmp_name'] ?? '');
$name = (string) ($upload['name'] ?? '');
@@ -31,16 +31,16 @@ class ImportTempFileService
if ($size > self::MAX_UPLOAD_BYTES) {
return ['ok' => false, 'code' => 'upload_too_large'];
}
if (!self::isAllowedExtension($name)) {
if (!$this->isAllowedExtension($name)) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
$dir = self::tmpDir();
$dir = $this->tmpDir();
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return ['ok' => false, 'code' => 'unexpected_error'];
}
self::cleanupExpired();
$this->cleanupExpired();
try {
$target = $dir . '/import_' . bin2hex(random_bytes(16)) . '.csv';
@@ -65,13 +65,13 @@ class ImportTempFileService
return ['ok' => true, 'path' => $target];
}
public static function delete(string $path): void
public function delete(string $path): void
{
$path = trim($path);
if ($path === '') {
return;
}
if (!self::isInTmpDir($path)) {
if (!$this->isInTmpDir($path)) {
return;
}
if (is_file($path)) {
@@ -79,9 +79,9 @@ class ImportTempFileService
}
}
public static function cleanupExpired(): void
public function cleanupExpired(): void
{
$dir = self::tmpDir();
$dir = $this->tmpDir();
if (!is_dir($dir)) {
return;
}
@@ -103,7 +103,7 @@ class ImportTempFileService
}
}
public static function storageBase(): string
public function storageBase(): string
{
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
return rtrim((string) APP_STORAGE_PATH, '/');
@@ -111,20 +111,20 @@ class ImportTempFileService
return rtrim(dirname(__DIR__, 3) . '/storage', '/');
}
public static function tmpDir(): string
public function tmpDir(): string
{
return self::storageBase() . self::TMP_SUBDIR;
return $this->storageBase() . self::TMP_SUBDIR;
}
private static function isAllowedExtension(string $name): bool
private function isAllowedExtension(string $name): bool
{
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return in_array($extension, ['csv', 'txt'], true);
}
private static function isInTmpDir(string $path): bool
private function isInTmpDir(string $path): bool
{
$tmpDir = self::tmpDir();
$tmpDir = $this->tmpDir();
$realTmpDir = realpath($tmpDir);
$realPath = realpath($path);
if ($realTmpDir === false || $realPath === false) {

View File

@@ -0,0 +1,61 @@
<?php
namespace MintyPHP\Service\Import\Profile;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\Org\DepartmentService;
class DepartmentImportGateway
{
public function __construct(
private readonly ?DepartmentRepository $departmentRepository = null,
private readonly ?TenantRepository $tenantRepository = null,
private readonly ?DepartmentService $departmentService = null
) {
}
public function findTenantByUuid(string $uuid): ?array
{
return $this->tenantRepository()->findByUuid($uuid);
}
public function findTenantById(int $id): ?array
{
return $this->tenantRepository()->find($id);
}
public function existsDepartmentCode(string $code): bool
{
return $this->departmentRepository()->existsByCode($code);
}
public function existsDepartmentByTenantAndDescription(int $tenantId, string $description): bool
{
return $this->departmentRepository()->existsByTenantAndDescription($tenantId, $description);
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
public function createDepartmentFromAdmin(array $input, int $currentUserId): array
{
return $this->departmentService()->createFromAdmin($input, $currentUserId);
}
private function departmentRepository(): DepartmentRepository
{
return $this->departmentRepository ?? new DepartmentRepository();
}
private function tenantRepository(): TenantRepository
{
return $this->tenantRepository ?? new TenantRepository();
}
private function departmentService(): DepartmentService
{
return $this->departmentService ?? new DepartmentService();
}
}

View File

@@ -2,12 +2,12 @@
namespace MintyPHP\Service\Import\Profile;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\Org\DepartmentService;
class DepartmentImportProfile implements ImportProfileInterface
{
public function __construct(private readonly DepartmentImportGateway $gateway)
{
}
public function key(): string
{
return 'departments';
@@ -81,7 +81,7 @@ class DepartmentImportProfile implements ImportProfileInterface
if (!$this->isUuid($tenantRaw)) {
$errors[] = ['code' => 'invalid_tenant_uuid'];
} else {
$tenant = TenantRepository::findByUuid($tenantRaw);
$tenant = $this->gateway->findTenantByUuid($tenantRaw);
if (!$tenant || (string) ($tenant['status'] ?? '') !== 'active') {
$errors[] = ['code' => 'tenant_not_found'];
}
@@ -91,7 +91,7 @@ class DepartmentImportProfile implements ImportProfileInterface
if (!$tenant) {
$defaultTenantId = (int) ($context['default_tenant_id'] ?? 0);
if ($defaultTenantId > 0) {
$defaultTenant = TenantRepository::find($defaultTenantId);
$defaultTenant = $this->gateway->findTenantById($defaultTenantId);
if ($defaultTenant && (string) ($defaultTenant['status'] ?? '') === 'active') {
$tenant = $defaultTenant;
}
@@ -139,7 +139,7 @@ class DepartmentImportProfile implements ImportProfileInterface
return ['status' => 'skipped', 'code' => 'department_exists'];
}
$result = DepartmentService::createFromAdmin([
$result = $this->gateway->createDepartmentFromAdmin([
'description' => (string) ($normalized['description'] ?? ''),
'tenant_id' => (int) ($normalized['tenant_id'] ?? 0),
'code' => (string) ($normalized['code'] ?? ''),
@@ -190,7 +190,7 @@ class DepartmentImportProfile implements ImportProfileInterface
if ($code === '') {
return false;
}
return DepartmentRepository::existsByCode($code);
return $this->gateway->existsDepartmentCode($code);
}
private function departmentExists(array $normalized): bool
@@ -200,7 +200,7 @@ class DepartmentImportProfile implements ImportProfileInterface
if ($tenantId <= 0 || $description === '') {
return false;
}
return DepartmentRepository::existsByTenantAndDescription($tenantId, $description);
return $this->gateway->existsDepartmentByTenantAndDescription($tenantId, $description);
}
private function parseActive(string $value): ?int

View File

@@ -0,0 +1,62 @@
<?php
namespace MintyPHP\Service\Import\Profile;
use MintyPHP\Repository\Access\RoleRepository;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Service\User\UserAccountService;
class UserImportGateway
{
public function __construct(
private readonly UserReadRepository $userReadRepository,
private readonly UserAccountService $userAccountService
) {
}
public function findUserByEmail(string $email): ?array
{
return $this->userReadRepository->findByEmail($email);
}
public function findTenantByUuid(string $uuid): ?array
{
return (new TenantRepository())->findByUuid($uuid);
}
public function findTenantById(int $id): ?array
{
return (new TenantRepository())->find($id);
}
public function findRoleByUuid(string $uuid): ?array
{
return (new RoleRepository())->findByUuid($uuid);
}
public function findRoleById(int $id): ?array
{
return (new RoleRepository())->find($id);
}
public function findDepartmentByUuid(string $uuid): ?array
{
return (new DepartmentRepository())->findByUuid($uuid);
}
public function findDepartmentById(int $id): ?array
{
return (new DepartmentRepository())->find($id);
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
public function createUserFromAdmin(array $input, int $currentUserId): array
{
return $this->userAccountService->createFromAdmin($input, $currentUserId);
}
}

View File

@@ -2,12 +2,6 @@
namespace MintyPHP\Service\Import\Profile;
use MintyPHP\Repository\Access\RoleRepository;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Repository\User\UserRepository;
use MintyPHP\Service\User\UserService;
class UserImportProfile implements ImportProfileInterface
{
/**
@@ -15,6 +9,10 @@ class UserImportProfile implements ImportProfileInterface
*/
private ?array $allowedLocales = null;
public function __construct(private readonly UserImportGateway $gateway)
{
}
public function key(): string
{
return 'users';
@@ -159,7 +157,7 @@ class UserImportProfile implements ImportProfileInterface
public function dryRunRow(array $normalized, array $context): array
{
$existing = UserRepository::findByEmail((string) ($normalized['email'] ?? ''));
$existing = $this->gateway->findUserByEmail((string) ($normalized['email'] ?? ''));
if ($existing) {
return ['status' => 'skipped', 'code' => 'email_exists'];
}
@@ -168,7 +166,7 @@ class UserImportProfile implements ImportProfileInterface
public function commitRow(array $normalized, array $context): array
{
$existing = UserRepository::findByEmail((string) ($normalized['email'] ?? ''));
$existing = $this->gateway->findUserByEmail((string) ($normalized['email'] ?? ''));
if ($existing) {
return ['status' => 'skipped', 'code' => 'email_exists'];
}
@@ -201,11 +199,11 @@ class UserImportProfile implements ImportProfileInterface
if ((int) ($normalized['active'] ?? 1) === 1) {
$input['active'] = 1;
} else {
// UserService::createFromAdmin expects a present-but-null value for inactive state.
// The admin create flow expects a present-but-null value for inactive state.
$input['active'] = null;
}
$result = UserService::createFromAdmin($input, (int) ($context['current_user_id'] ?? 0));
$result = $this->gateway->createUserFromAdmin($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');
@@ -335,9 +333,9 @@ class UserImportProfile implements ImportProfileInterface
{
$tenant = null;
if ($this->isUuid($value)) {
$tenant = TenantRepository::findByUuid($value);
$tenant = $this->gateway->findTenantByUuid($value);
} elseif (ctype_digit($value)) {
$tenant = TenantRepository::find((int) $value);
$tenant = $this->gateway->findTenantById((int) $value);
}
if (!$tenant) {
@@ -350,9 +348,9 @@ class UserImportProfile implements ImportProfileInterface
{
$role = null;
if ($this->isUuid($value)) {
$role = RoleRepository::findByUuid($value);
$role = $this->gateway->findRoleByUuid($value);
} elseif (ctype_digit($value)) {
$role = RoleRepository::find((int) $value);
$role = $this->gateway->findRoleById((int) $value);
}
if (!$role) {
@@ -365,9 +363,9 @@ class UserImportProfile implements ImportProfileInterface
{
$department = null;
if ($this->isUuid($value)) {
$department = DepartmentRepository::findByUuid($value);
$department = $this->gateway->findDepartmentByUuid($value);
} elseif (ctype_digit($value)) {
$department = DepartmentRepository::find((int) $value);
$department = $this->gateway->findDepartmentById((int) $value);
}
if (!$department) {