presseportale/app/Services/Import/UserImporter.php
Kevin Adametz a0c2e663dc legacy: --since-Delta für legacy:import + Importer setzen Timestamps wieder korrekt
--since=<Zeitpunkt> schränkt den Quell-Scan auf Legacy-Zeilen ein, deren
created_at ODER updated_at im Fenster liegt (users/companies/contacts/
press-releases; Kategorien und link-associations bleiben ungefiltert).
Kombiniert mit --force werden geänderte Bestandszeilen aktualisiert —
damit dauert ein Tages-Delta Sekunden statt eines Volldurchlaufs über
150k+ Zeilen.

Dazu Bugfix, der den Delta erst rund macht: User-/Company-/Contact-
Importer übergaben created_at/updated_at innerhalb von updateOrCreate —
die Felder sind nicht fillable und wurden stillschweigend verworfen
(Live-Befund: NULL-Timestamps, bisher nur durch legacy:fix-timestamps
geheilt). Jetzt explizit per forceFill wie im PressReleaseImporter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:03:07 +00:00

283 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Import;
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Models\LegacyImportMap;
use App\Models\Profile;
use App\Models\User;
use App\Services\Auth\UserRolePermissionSyncService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class UserImporter
{
/**
* Legacy-Gruppen → Spatie-Rollen.
*
* Achtung: Die Alt-Gruppe 2 hieß dort „editor", meinte aber die normale
* Self-Publisher-Masse (Kunden, die ihre eigenen PMs einstellen). Im neuen
* Rollenmodell ist `editor` ein Redaktions-/Staff-Recht MIT Admin-Panel-
* Zugriff (canAccessAdmin). Legacy-Publisher gehören daher auf `customer`
* (nur „Mein Bereich"), nicht auf `editor`. `editor` wird ausschließlich
* manuell an echtes Personal vergeben.
*/
private const GROUP_ROLE_MAP = [
1 => 'admin',
2 => 'customer',
3 => 'api-only',
4 => 'customer',
];
private const DEFAULT_ROLE = 'customer';
private const CHUNK_SIZE = 500;
/** Registration-Type-Mapping aus Legacy-Wert */
private const REG_TYPE_MAP = [
'agency' => RegistrationType::Agency,
'company' => RegistrationType::Company,
'apiuser' => RegistrationType::ApiUser,
];
public function __construct(
private readonly UserRolePermissionSyncService $roleSync,
) {}
public function run(ImportContext $ctx): ImportResult
{
$result = new ImportResult;
$conn = $ctx->connection;
$portal = $ctx->portalEnum;
$legacyPortal = $ctx->legacyPortalValue();
// Lade alle Gruppen-Zuordnungen in Memory (klein genug)
$userGroups = DB::connection($conn)
->table('sf_guard_user_group')
->get()
->groupBy('user_id')
->map(fn ($rows) => $rows->pluck('group_id')->first());
DB::connection($conn)
->table('sf_guard_user')
->join('sf_guard_user_profile', 'sf_guard_user.id', '=', 'sf_guard_user_profile.user_id')
->select([
'sf_guard_user.id as legacy_id',
'sf_guard_user.username',
'sf_guard_user.is_active',
'sf_guard_user.is_super_admin',
'sf_guard_user.last_login',
'sf_guard_user.ip_address',
'sf_guard_user.created_at',
'sf_guard_user.updated_at',
'sf_guard_user_profile.email',
'sf_guard_user_profile.salutation_id',
'sf_guard_user_profile.title',
'sf_guard_user_profile.first_name',
'sf_guard_user_profile.last_name',
'sf_guard_user_profile.address',
'sf_guard_user_profile.country_id',
'sf_guard_user_profile.phone',
'sf_guard_user_profile.birthdate',
'sf_guard_user_profile.language',
'sf_guard_user_profile.backlink_url',
'sf_guard_user_profile.show_stats',
'sf_guard_user_profile.validation_date',
'sf_guard_user_profile.contract_date',
'sf_guard_user_profile.registration_type',
'sf_guard_user_profile.validate',
'sf_guard_user_profile.tax_id_number',
'sf_guard_user_profile.tax_exempt',
'sf_guard_user_profile.tax_exempt_reason',
'sf_guard_user_profile.disable_footer_code',
])
->when($ctx->since, fn ($query) => $query->where(function ($window) use ($ctx): void {
$window->where('sf_guard_user.created_at', '>=', $ctx->since)
->orWhere('sf_guard_user.updated_at', '>=', $ctx->since);
}))
->orderBy('sf_guard_user.id')
->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $portal, $legacyPortal, $userGroups): void {
foreach ($rows as $row) {
try {
$this->importRow($row, $ctx, $result, $portal, $legacyPortal, $userGroups);
} catch (\Throwable $e) {
$result->addError("User legacy_id={$row->legacy_id}: {$e->getMessage()}");
}
}
});
return $result;
}
private function importRow(
object $row,
ImportContext $ctx,
ImportResult $result,
Portal $portal,
string $legacyPortal,
Collection $userGroups,
): void {
// E-Mail-Adresse ist Pflicht
$email = trim((string) $row->email);
if (blank($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$result->incrementSkipped();
return;
}
// Idempotenz-Check via legacy_import_map
$alreadyImported = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'sf_guard_user')
->where('legacy_id', $row->legacy_id)
->exists();
if ($alreadyImported && ! $ctx->force) {
$result->incrementSkipped();
return;
}
if ($ctx->dryRun) {
$result->incrementImported();
return;
}
$name = trim(($row->first_name ?? '').' '.($row->last_name ?? ''));
if (blank($name)) {
$name = $row->username;
}
$language = in_array($row->language, ['de', 'en']) ? $row->language : 'de';
$regType = self::REG_TYPE_MAP[$row->registration_type] ?? RegistrationType::ExistingLegacy;
$user = User::withoutTimestamps(function () use ($email, $name, $portal, $regType, $language, $row, $legacyPortal): User {
$user = User::query()->updateOrCreate(
['email' => $email],
[
'name' => $name,
'portal' => $portal->value,
'registration_type' => $regType->value,
'language' => $language,
'is_active' => (bool) $row->is_active,
'last_login_at' => $row->last_login ?: null,
'last_login_ip' => $row->ip_address ?: null,
'legacy_portal' => $legacyPortal,
'legacy_id' => $row->legacy_id,
// Kein Passwort – User erhält Go-Live-Reset-Mail (D-09)
]
);
// is_super_admin und die Timestamps sind nicht mass-assignable und
// würden von updateOrCreate verworfen – im vertrauenswürdigen Import
// explizit per forceFill setzen (Muster PressReleaseImporter).
$user->forceFill([
'is_super_admin' => (bool) $row->is_super_admin,
'created_at' => $row->created_at ?? now(),
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
])->save();
return $user;
});
// Rolle zuweisen
$groupId = $userGroups->get($row->legacy_id);
$roleName = self::GROUP_ROLE_MAP[$groupId] ?? self::DEFAULT_ROLE;
$this->roleSync->assignRoleAndSyncPermissions($user, $roleName);
Profile::query()->updateOrCreate(
['user_id' => $user->id],
[
'salutation_key' => $this->mapSalutation((int) ($row->salutation_id ?? 0)),
'title' => $this->cleanText($row->title ?? null, 80),
'first_name' => $this->cleanText($row->first_name ?? null, 80),
'last_name' => $this->cleanText($row->last_name ?? null, 80),
'phone' => $this->cleanText($row->phone ?? null, 40),
'address' => $this->cleanText($row->address ?? null, 1000),
'country_code' => $this->mapCountry((int) ($row->country_id ?? 0)),
'birthdate' => $this->validDateOrNull($row->birthdate ?? null),
'backlink_url' => $this->cleanText($row->backlink_url ?? null, 255),
'show_stats' => (bool) ($row->show_stats ?? false),
'validation_date' => $this->validDateOrNull($row->validation_date ?? null),
'contract_date' => $this->validDateOrNull($row->contract_date ?? null),
'validate_token' => $this->cleanText($row->validate ?? null, 64),
'tax_id_number' => $this->cleanText($row->tax_id_number ?? null, 255),
'tax_exempt' => (bool) ($row->tax_exempt ?? false),
'tax_exempt_reason' => $this->cleanText($row->tax_exempt_reason ?? null, 1000),
'disable_footer_code' => (bool) ($row->disable_footer_code ?? false),
]
);
// Import-Map eintragen
LegacyImportMap::query()->updateOrCreate(
[
'legacy_portal' => $legacyPortal,
'legacy_table' => 'sf_guard_user',
'legacy_id' => $row->legacy_id,
],
[
'target_table' => 'users',
'target_id' => $user->id,
'imported_at' => now(),
]
);
if ($alreadyImported) {
$result->incrementUpdated();
} else {
$result->incrementImported();
}
}
private function mapSalutation(int $salutationId): ?string
{
return match ($salutationId) {
1 => 'mr',
2 => 'mrs',
3 => 'none',
default => null,
};
}
private function mapCountry(int $countryId): ?string
{
return match ($countryId) {
177 => 'DE',
80 => 'AT',
196 => 'CH',
115 => 'LI',
117 => 'LU',
21 => 'BE',
2 => 'NL',
165 => 'FR',
30 => 'GB',
229 => 'US',
default => null,
};
}
private function cleanText(?string $value, int $maxLength): ?string
{
if (blank($value)) {
return null;
}
$clean = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$clean = preg_replace('/[\x00-\x1F\x7F\xC2\xA0]/u', ' ', $clean) ?? $clean;
$clean = trim((string) $clean);
return blank($clean) ? null : mb_substr($clean, 0, $maxLength);
}
private function validDateOrNull(mixed $value): ?string
{
if (blank($value) || str_starts_with((string) $value, '0000-00-00')) {
return null;
}
return (string) $value;
}
}