presseportale/app/Services/Import/PressReleaseImporter.php
Kevin Adametz b4b2e013d6 recht: Meldeverfahren v2 Phasen 1-4 — Re-Sync-Sperre, Anonymisierung, Magic-Link-Intake, Self-Service-Korrektur
Phase 1: sync_locked_at auf press_releases (Importer überspringt gesperrte
PMs auch bei --force), LegalRequest um Verifikation/Ausführung erweitert,
verschlüsselte Vorher-Snapshots mit Löschfrist (snapshots:purge, täglich).

Phase 2: AnonymizationService (Unicode-Wortgrenzen-Schwärzung, Pflicht-Diff,
transaktionale Ausführung mit Snapshot, Kontakt-Detach, Bild-Löschung,
Sitemap-Invalidierung, Verlauf ohne Klartext-Altwerte); Admin-Queue kann
DSGVO-Anfragen anonymisieren oder depublizieren, inkl. Snapshot-Restore.

Phase 3: Deflektions-Trichter „PM melden/ändern" (melden/pm) mit Kosten-
Tabelle vorab; Magic-Link geht nur an die hinterlegte Pressekontaktadresse
(neuer purpose press_release_report), atomare Verifikation, Anliegen-Auswahl
erzeugt verifizierte Queue-Anfragen; toter-Kanal-Fallback; Owner-Einstieg.

Phase 4: Panel-Abschnitt „Änderung & Korrektur" — Levenshtein-Gate (5 %,
Mini-Edit-Ausnahme ≤ 2 Zeichen) stuft klein/groß ein; kleine Korrektur
kostenlos sofort live (Snapshot, Sperre, Verlauf, Re-Score), größere als
Change-Anfrage (Interim bis 10-€-Checkout, Phase 7).

Entwicklungsplan mit Entscheidungen E1-E8 und offenen Punkten in
docs/Decision-Update; 41 Pest-Tests (tests/Feature/Legal) grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:56:34 +00:00

300 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\PressReleaseStatus;
use App\Models\LegacyImportMap;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PressReleaseImporter
{
private const CHUNK_SIZE = 200;
/** Legacy-Status → neuer Status */
private const STATUS_MAP = [
'new' => PressReleaseStatus::Draft,
'edited' => PressReleaseStatus::Draft,
'prepublished' => PressReleaseStatus::Review,
'published' => PressReleaseStatus::Published,
'rejected' => PressReleaseStatus::Rejected,
];
public function run(ImportContext $ctx): ImportResult
{
$result = new ImportResult;
$conn = $ctx->connection;
$legacyPortal = $ctx->legacyPortalValue();
$portal = $ctx->portalEnum;
// Bilder und Kontakt-Pivots vorladen
$images = DB::connection($conn)
->table('press_release_image')
->get()
->groupBy('press_release_id');
$prContacts = DB::connection($conn)
->table('press_release_contact')
->get()
->groupBy('press_release_id');
DB::connection($conn)
->table('press_release')
->when($ctx->since, fn ($query) => $query->where(function ($window) use ($ctx): void {
$window->where('created_at', '>=', $ctx->since)
->orWhere('updated_at', '>=', $ctx->since);
}))
->orderBy('id')
->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $legacyPortal, $portal, $images, $prContacts): void {
foreach ($rows as $row) {
try {
$this->importRow($row, $ctx, $result, $legacyPortal, $portal, $images, $prContacts);
} catch (\Throwable $e) {
$result->addError("PR legacy_id={$row->id}: {$e->getMessage()}");
}
}
});
return $result;
}
private function importRow(
object $row,
ImportContext $ctx,
ImportResult $result,
string $legacyPortal,
Portal $portal,
Collection $images,
Collection $prContacts,
): void {
$alreadyImported = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'press_release')
->where('legacy_id', $row->id)
->exists();
if ($alreadyImported && ! $ctx->force) {
$result->incrementSkipped();
return;
}
// Re-Sync-Sperre (Meldeverfahren): Eine über das Meldeverfahren
// mutierte PM (Korrektur/Anonymisierung/Depublizierung) darf auch
// per --force nie mit dem Legacy-Stand überschrieben werden — sonst
// kommt z. B. ein geschwärzter Klarname zurück.
if ($alreadyImported && $this->isSyncLocked($legacyPortal, $row->id)) {
$result->incrementSkipped();
return;
}
if ($ctx->dryRun) {
$result->incrementImported();
return;
}
// User aus Import-Map
$userId = null;
if ($row->user_id) {
$userMap = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'sf_guard_user')
->where('legacy_id', $row->user_id)
->first();
$userId = $userMap?->target_id;
}
if (! $userId) {
$result->incrementSkipped();
return;
}
// Company aus Import-Map
$companyId = null;
if ($row->company_id) {
$companyMap = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'company')
->where('legacy_id', $row->company_id)
->first();
$companyId = $companyMap?->target_id;
}
// Kategorie aus Import-Map
$categoryId = null;
if ($row->category_id) {
$catMap = LegacyImportMap::query()
->where('legacy_table', 'category')
->where('legacy_id', $row->category_id)
->first();
$categoryId = $catMap?->target_id;
}
// Fallback-Kategorie (erste verfügbare)
if (! $categoryId) {
$categoryId = LegacyImportMap::query()
->where('legacy_table', 'category')
->value('target_id');
}
if (! $categoryId) {
$result->incrementSkipped();
return;
}
$status = self::STATUS_MAP[$row->status] ?? PressReleaseStatus::Draft;
$language = in_array($row->language, ['de', 'en']) ? $row->language : 'de';
// Beim Update (--force): bestehende uuid + slug behalten – öffentliche
// Referenzen (API-uuid, slug-URLs) dürfen sich beim Re-Import/Delta-Lauf
// nicht ändern.
$existingPr = $alreadyImported
? PressRelease::withoutGlobalScopes()
->where('legacy_portal', $legacyPortal)
->where('legacy_id', $row->id)
->first(['id', 'uuid', 'slug'])
: null;
$slug = $existingPr?->slug ?? $this->uniqueSlug(
$row->slug ?: Str::slug($row->title) ?: 'pressemitteilung',
$portal->value,
$language,
$existingPr?->id,
);
// Veröffentlichungsdatum = Legacy-`created_at` (Anlage-/Publikationsdatum,
// im Altsystem der indexierte Sortierschlüssel). Bewusst NICHT `updated_at`:
// dort steht der letzte Bearbeitungs-/Massen-Update-Stempel (bei vielen
// Alt-Datensätzen das Migrationsdatum), was im Frontend – das nach
// `published_at` sortiert/clustert – ein falsches, "frisches" Datum erzeugt.
$publishedAt = ($status === PressReleaseStatus::Published)
? $row->created_at
: null;
$pr = PressRelease::withoutTimestamps(function () use (
$legacyPortal, $row, $portal, $userId, $companyId, $categoryId,
$language, $slug, $status, $publishedAt, $existingPr,
): PressRelease {
$pr = PressRelease::withoutGlobalScopes()->updateOrCreate(
['legacy_portal' => $legacyPortal, 'legacy_id' => $row->id],
[
'uuid' => $existingPr?->uuid ?? (string) Str::uuid(),
'portal' => $portal->value,
'user_id' => $userId,
'company_id' => $companyId,
'category_id' => $categoryId,
'language' => $language,
'title' => $row->title ?: 'Ohne Titel',
'slug' => $slug,
'text' => $row->text ?: '',
'backlink_url' => $row->backlink_url ?: null,
'keywords' => $row->keywords ?: null,
'status' => $status->value,
'hits' => max(0, (int) $row->hits),
'teaser_begin' => max(0, (int) ($row->teaser_begin ?? 0)),
'teaser_end' => max(0, (int) ($row->teaser_end ?? 0)),
'no_export' => (bool) ($row->no_export ?? false),
'published_at' => $publishedAt,
]
);
// created_at/updated_at sind nicht fillable und werden von
// updateOrCreate verworfen → direkt setzen, damit das Anlage-/
// Bearbeitungsdatum bereits durch den Import korrekt migriert ist
// (legacy:fix-timestamps bleibt der schnelle Cross-DB-Resync).
$pr->forceFill([
'created_at' => $row->created_at ?? now(),
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
])->save();
return $pr;
});
foreach ($images->get($row->id, []) as $img) {
PressReleaseImage::withoutGlobalScopes()->updateOrCreate(
['legacy_portal' => $legacyPortal, 'legacy_id' => $img->id],
[
'press_release_id' => $pr->id,
'path' => $img->image,
'title' => $img->title ?: null,
'description' => $img->description ?: null,
'copyright' => $img->copyright ?: null,
'is_preview' => (bool) $img->is_preview_image,
'sort_order' => 0,
]
);
}
// Kontakt-Pivot importieren
$contactIds = [];
foreach ($prContacts->get($row->id, []) as $pivot) {
$contactMap = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'contact')
->where('legacy_id', $pivot->contact_id)
->first();
if ($contactMap) {
$contactIds[] = $contactMap->target_id;
}
}
if ($contactIds !== []) {
$pr->contacts()->syncWithoutDetaching($contactIds);
}
LegacyImportMap::query()->updateOrCreate(
[
'legacy_portal' => $legacyPortal,
'legacy_table' => 'press_release',
'legacy_id' => $row->id,
],
[
'target_table' => 'press_releases',
'target_id' => $pr->id,
'imported_at' => now(),
]
);
if ($alreadyImported) {
$result->incrementUpdated();
} else {
$result->incrementImported();
}
}
private function isSyncLocked(string $legacyPortal, int $legacyId): bool
{
return PressRelease::withoutGlobalScopes()
->where('legacy_portal', $legacyPortal)
->where('legacy_id', $legacyId)
->whereNotNull('sync_locked_at')
->exists();
}
private function uniqueSlug(string $base, string $portal, string $language, ?int $excludeId = null): string
{
$slug = $base;
$i = 2;
while (PressRelease::withoutGlobalScopes()
->where('portal', $portal)
->where('language', $language)
->where('slug', $slug)
->when($excludeId, fn ($q) => $q->where('id', '!=', $excludeId))
->exists()
) {
$slug = $base.'-'.$i++;
}
return $slug;
}
}