Files
breadcrumb-the-shire/lib/Service/Import/ImportService.php

614 lines
22 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Service\Import;
use MintyPHP\Service\Access\PermissionService;
2026-02-23 12:58:19 +01:00
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Audit\ImportAuditService;
use MintyPHP\Service\Import\Profile\ImportProfileInterface;
2026-02-23 12:58:19 +01:00
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\User\UserScopeGateway;
class ImportService
{
private const MAX_ROWS = 20000;
private const MAX_ERROR_ROWS = 500;
private const PROFILE_PERMISSION_MAP = [
'users' => PermissionService::USERS_IMPORT,
'departments' => PermissionService::DEPARTMENTS_IMPORT,
];
2026-02-23 12:58:19 +01:00
/**
* @param array<string, ImportProfileInterface> $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
) {
}
/**
* @return array<string, ImportProfileInterface>
*/
2026-02-23 12:58:19 +01:00
public function profiles(): array
{
2026-02-23 12:58:19 +01:00
return $this->profiles;
}
2026-02-23 12:58:19 +01:00
public function profile(string $type): ?ImportProfileInterface
{
$type = trim($type);
if ($type === '') {
return null;
}
2026-02-23 12:58:19 +01:00
return $this->profiles[$type] ?? null;
}
/**
* @return array<int, array{key:string,label:string}>
*/
2026-02-23 12:58:19 +01:00
public function listProfileOptions(): array
{
$options = [];
2026-02-23 12:58:19 +01:00
foreach ($this->profiles() as $profile) {
$options[] = [
'key' => $profile->key(),
'label' => $profile->label(),
];
}
return $options;
}
2026-02-23 12:58:19 +01:00
public function requiredPermissionForType(string $type): string
{
$type = trim($type);
if ($type === '') {
return '';
}
return self::PROFILE_PERMISSION_MAP[$type] ?? '';
}
/**
* @param array<string, mixed> $upload
* @return array<string, mixed>
*/
2026-02-23 12:58:19 +01:00
public function analyzeUpload(array $upload, string $type, int $currentUserId): array
{
2026-02-23 12:58:19 +01:00
$profile = $this->profile($type);
if (!$profile) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
2026-02-23 12:58:19 +01:00
$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'] ?? '');
2026-02-23 12:58:19 +01:00
$analysis = $this->csvReaderService->analyze($path, self::MAX_ROWS);
if (!$analysis['ok']) {
2026-02-23 12:58:19 +01:00
$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);
2026-02-23 12:58:19 +01:00
$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(),
2026-02-23 12:58:19 +01:00
'auto_mapping' => $this->buildAutoMapping($headers, $profile),
];
}
/**
* @param array<string, mixed> $mappingInput
* @return array<string, mixed>
*/
2026-02-23 12:58:19 +01:00
public function preview(string $token, array $mappingInput, int $currentUserId): array
{
2026-02-23 12:58:19 +01:00
$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'];
}
2026-02-23 12:58:19 +01:00
$profile = $this->profile((string) ($state['type'] ?? ''));
if (!$profile) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
2026-02-23 12:58:19 +01:00
$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()
2026-02-23 12:58:19 +01:00
&& $this->usesAssignmentTargets($mappedTargets)
&& !$this->canImportAssignments($currentUserId)
) {
return ['ok' => false, 'code' => 'assignment_permission_required'];
}
2026-02-23 12:58:19 +01:00
return $this->process($state, $mappingResult['mapping'], $profile, $currentUserId, false);
}
/**
* @param array<string, mixed> $mappingInput
* @return array<string, mixed>
*/
2026-02-23 12:58:19 +01:00
public function commit(string $token, array $mappingInput, int $currentUserId): array
{
2026-02-23 12:58:19 +01:00
$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'];
}
2026-02-23 12:58:19 +01:00
$profile = $this->profile((string) ($state['type'] ?? ''));
if (!$profile) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
2026-02-23 12:58:19 +01:00
$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()
2026-02-23 12:58:19 +01:00
&& $this->usesAssignmentTargets($mappedTargets)
&& !$this->canImportAssignments($currentUserId)
) {
return ['ok' => false, 'code' => 'assignment_permission_required'];
}
2026-02-23 12:58:19 +01:00
$auditRunId = $this->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
);
2026-02-23 12:58:19 +01:00
$result = ['ok' => false, 'code' => 'unexpected_error', 'processed' => 0, 'created' => 0, 'skipped' => 0, 'failed' => 1];
$forcedAuditStatus = null;
try {
2026-02-23 12:58:19 +01:00
$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 {
2026-02-23 12:58:19 +01:00
$this->importAuditService->finishRun($auditRunId, $result, $forcedAuditStatus);
}
2026-02-23 12:58:19 +01:00
$this->importTempFileService->delete((string) ($state['path'] ?? ''));
$this->importStateStoreService->clearState($token);
2026-02-23 12:58:19 +01:00
return $result;
}
/**
* @return array<string, mixed>|null
*/
2026-02-23 12:58:19 +01:00
public function state(string $token): ?array
{
2026-02-23 12:58:19 +01:00
return $this->importStateStoreService->getState($token);
}
/**
* @param array<string, mixed> $state
* @param array<string, string> $mapping
* @return array<string, mixed>
*/
2026-02-23 12:58:19 +01:00
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'];
}
2026-02-23 12:58:19 +01:00
$context = $this->buildContext($currentUserId);
$seenRowKeys = [];
$processed = 0;
$created = 0;
$skipped = 0;
$failed = 0;
$wouldCreate = 0;
$errors = [];
$errorCounts = [];
2026-02-23 12:58:19 +01:00
$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 {
2026-02-23 12:58:19 +01:00
$mapped = $this->mapRow($rowAssoc, $mapping);
$validation = $profile->validateMappedRow($mapped, $context);
if (!$validation['ok']) {
$failed++;
foreach ($validation['errors'] as $error) {
2026-02-23 12:58:19 +01:00
$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);
$duplicateKey = $profile->inFileDuplicateKey($normalized);
if ($duplicateKey !== null && $duplicateKey !== '') {
if (isset($seenRowKeys[$duplicateKey])) {
$skipped++;
2026-02-23 12:58:19 +01:00
$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';
2026-02-23 12:58:19 +01:00
$this->incrementErrorCount($errorCounts, $errorCode);
$this->pushError($errors, $lineNumber, $identifier, $errorCode, $message);
return;
}
$failed++;
$errorCode = $code !== '' ? $code : 'unexpected_error';
2026-02-23 12:58:19 +01:00
$this->incrementErrorCount($errorCounts, $errorCode);
$this->pushError($errors, $lineNumber, $identifier, $errorCode, $message);
} catch (\Throwable $exception) {
$failed++;
2026-02-23 12:58:19 +01:00
$this->incrementErrorCount($errorCounts, 'unexpected_error');
if ($identifier === '' && $mapped) {
try {
$identifier = $profile->rowIdentifier($mapped);
} catch (\Throwable $innerException) {
$identifier = '';
}
}
2026-02-23 12:58:19 +01:00
$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<int, string> $headers
* @return array<string, mixed>
*/
2026-02-23 12:58:19 +01:00
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<string, string> $mapping
* @param array<string, string> $rowAssoc
* @return array<string, string>
*/
2026-02-23 12:58:19 +01:00
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<int, string> $headers
* @return array<string, string>
*/
2026-02-23 12:58:19 +01:00
private function buildAutoMapping(array $headers, ImportProfileInterface $profile): array
{
$mapping = [];
$aliases = $profile->autoMapAliases();
$normalizedAliasMap = [];
foreach ($aliases as $target => $targetAliases) {
2026-02-23 12:58:19 +01:00
$normalizedAliasMap[$this->normalizeHeader($target)] = $target;
foreach ($targetAliases as $alias) {
2026-02-23 12:58:19 +01:00
$normalizedAliasMap[$this->normalizeHeader($alias)] = $target;
}
}
$assignedTargets = [];
foreach ($headers as $header) {
2026-02-23 12:58:19 +01:00
$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<int, string> $mappedTargets
*/
2026-02-23 12:58:19 +01:00
private function usesAssignmentTargets(array $mappedTargets): bool
{
foreach ($mappedTargets as $target) {
if (in_array($target, ['tenant', 'role', 'department'], true)) {
return true;
}
}
return false;
}
2026-02-23 12:58:19 +01:00
private function canImportAssignments(int $userId): bool
{
2026-02-23 12:58:19 +01:00
return $this->permissionGateway->userHas($userId, PermissionService::USERS_IMPORT_ASSIGNMENTS);
}
/**
* @return array<string, mixed>
*/
2026-02-23 12:58:19 +01:00
private function buildContext(int $currentUserId): array
{
2026-02-23 12:58:19 +01:00
$isGlobalAdmin = $this->userScopeGateway->hasGlobalAccess($currentUserId);
return [
'current_user_id' => $currentUserId,
'is_global_admin' => $isGlobalAdmin,
2026-02-23 12:58:19 +01:00
'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),
];
}
2026-02-23 12:58:19 +01:00
private function newToken(): string
{
try {
return bin2hex(random_bytes(24));
} catch (\Throwable $exception) {
return str_replace('.', '', uniqid('import_', true));
}
}
2026-02-23 12:58:19 +01:00
private function normalizeHeader(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
2026-02-23 12:58:19 +01:00
$value = $this->lower($value);
$value = str_replace(['-', '.', ' '], '_', $value);
$value = preg_replace('/[^a-z0-9_]/', '', $value) ?? '';
$value = preg_replace('/_+/', '_', $value) ?? '';
return trim($value, '_');
}
/**
* @param array<int, array{row_number:int,identifier:string,code:string,message:string}> $errors
*/
2026-02-23 12:58:19 +01:00
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,
2026-02-23 12:58:19 +01:00
'message' => $message !== null && $message !== '' ? $message : $this->defaultMessageForCode($code),
];
}
/**
* @param array<string, int> $errorCounts
*/
2026-02-23 12:58:19 +01:00
private function incrementErrorCount(array &$errorCounts, string $code): void
{
$code = trim($code);
if ($code === '') {
return;
}
$errorCounts[$code] = (int) ($errorCounts[$code] ?? 0) + 1;
}
2026-02-23 12:58:19 +01:00
private function defaultMessageForCode(string $code): string
{
$map = [
2026-02-23 12:58:19 +01:00
'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'),
];
2026-02-23 12:58:19 +01:00
return $map[$code] ?? $this->translate('Unexpected error during import');
}
2026-02-23 12:58:19 +01:00
public function messageForCode(string $code): string
{
2026-02-23 12:58:19 +01:00
return $this->defaultMessageForCode(trim($code));
}
2026-02-23 12:58:19 +01:00
private function lower(string $value): string
{
if (function_exists('mb_strtolower')) {
return mb_strtolower($value, 'UTF-8');
}
return strtolower($value);
}
2026-02-23 12:58:19 +01:00
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));
}
}