12-05-2026 Frontend dev
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
Kevin Adametz 2026-05-12 18:32:33 +02:00
parent 405df0a122
commit 5b8bdf4182
779 changed files with 480564 additions and 6241 deletions

View file

@ -0,0 +1,109 @@
<?php
namespace App\Services\Admin;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use Illuminate\Support\Facades\Cache;
class AdminPerformanceCache
{
public const DashboardStats = 'admin.dashboard.stats';
public const PressReleaseStats = 'admin.press-releases.stats';
public const PressReleaseReviewCount = 'admin.press-releases.review-count';
public const PressReleaseCategoryOptions = 'admin.press-releases.category-options';
public const ActiveCategoryOptions = 'admin.categories.active-options';
public const RoleOptions = 'admin.roles.options';
public const UserStats = 'admin.users.stats';
public const NewsletterStats = 'admin.newsletter.stats';
public const PresetAreas = 'admin.presets.areas';
public const PresetTypes = 'admin.presets.types';
public const StatsTtl = 60;
public const OptionsTtl = 300;
public function remember(string $key, int $seconds, callable $callback): mixed
{
if (app()->runningUnitTests()) {
return $callback();
}
return Cache::memo()->remember($key, $seconds, $callback);
}
public function flush(): void
{
foreach ($this->keys() as $key) {
Cache::memo()->forget($key);
}
}
public function pressReleaseReviewCount(): int
{
return (int) $this->remember(self::PressReleaseReviewCount, self::StatsTtl, function (): int {
return PressRelease::query()
->withoutGlobalScopes()
->where('status', PressReleaseStatus::Review->value)
->count();
});
}
public function companiesStatsKey(?string $portal): string
{
return 'admin.companies.stats.'.($portal ?? 'all');
}
public function contactsStatsKey(?string $portal): string
{
return 'admin.contacts.stats.'.($portal ?? 'all');
}
public function categoriesStatsKey(?string $portal): string
{
return 'admin.categories.stats.'.($portal ?? 'all');
}
public function permissionGroupsKey(string $guard): string
{
return "admin.permissions.groups.{$guard}";
}
/**
* @return list<string>
*/
private function keys(): array
{
$portalKeys = collect([null, ...array_map(
static fn (Portal $portal): string => $portal->value,
Portal::cases(),
)]);
return [
self::DashboardStats,
self::PressReleaseStats,
self::PressReleaseReviewCount,
self::PressReleaseCategoryOptions,
self::ActiveCategoryOptions,
self::RoleOptions,
self::UserStats,
self::NewsletterStats,
self::PresetAreas,
self::PresetTypes,
$this->permissionGroupsKey('web'),
...$portalKeys->map(fn (?string $portal): string => $this->companiesStatsKey($portal))->all(),
...$portalKeys->map(fn (?string $portal): string => $this->contactsStatsKey($portal))->all(),
...$portalKeys->map(fn (?string $portal): string => $this->categoriesStatsKey($portal))->all(),
];
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Services\Admin;
use Illuminate\Database\Events\QueryExecuted;
class AdminRequestPerformanceMetrics
{
private bool $active = false;
private int $queryCount = 0;
private float $databaseTimeMs = 0.0;
/**
* @var list<array{sql: string, time_ms: float, connection: ?string}>
*/
private array $slowQueries = [];
public function start(): void
{
$this->active = true;
$this->queryCount = 0;
$this->databaseTimeMs = 0.0;
$this->slowQueries = [];
}
/**
* @return array{database_time_ms: float, query_count: int, slow_queries: list<array{sql: string, time_ms: float, connection: ?string}>}
*/
public function snapshot(): array
{
return [
'database_time_ms' => round($this->databaseTimeMs, 2),
'query_count' => $this->queryCount,
'slow_queries' => $this->slowQueries,
];
}
public function stop(): void
{
$this->active = false;
}
public function record(QueryExecuted $query): void
{
if (! $this->active) {
return;
}
$this->queryCount++;
$this->databaseTimeMs += $query->time;
if ($query->time < $this->slowQueryThresholdMs()) {
return;
}
if (count($this->slowQueries) >= $this->maxSlowQueries()) {
return;
}
$this->slowQueries[] = [
'sql' => $query->sql,
'time_ms' => round($query->time, 2),
'connection' => $query->connectionName,
];
}
private function slowQueryThresholdMs(): int
{
return (int) config('admin_performance.slow_requests.slow_query_threshold_ms', 50);
}
private function maxSlowQueries(): int
{
return max(0, (int) config('admin_performance.slow_requests.max_slow_queries', 5));
}
}

View file

@ -0,0 +1,323 @@
<?php
namespace App\Services\Admin;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Throwable;
class AdminSlowRequestReporter
{
/**
* @param array{
* from?: ?string,
* to?: ?string,
* route?: ?string,
* path?: ?string,
* status?: ?int,
* min_duration_ms?: ?int
* } $filters
* @param list<string>|null $paths
* @return array{
* summary: array<string, int|float|string|null>,
* top_routes: list<array<string, mixed>>,
* top_paths: list<array<string, mixed>>,
* status_codes: list<array<string, mixed>>,
* slowest_requests: list<array<string, mixed>>,
* query_heavy_requests: list<array<string, mixed>>,
* slow_queries: list<array<string, mixed>>,
* explain_plans: list<array{sql: string, explainable: bool, plan: list<array<string, mixed>>, error: ?string}>,
* entries: list<array<string, mixed>>,
* files: list<string>
* }
*/
public function report(array $filters = [], int $top = 10, int $limit = 50, ?array $paths = null): array
{
$top = max(1, $top);
$limit = max(1, $limit);
$files = $this->files($paths);
$entries = $this->entries($files)
->filter(fn (array $entry): bool => $this->matchesFilters($entry, $filters))
->sortByDesc('timestamp')
->values();
$slowQueries = $this->slowQueries($entries, $top);
return [
'summary' => $this->summary($entries, $files),
'top_routes' => $this->topBy($entries, 'route_name', $top),
'top_paths' => $this->topBy($entries, 'path', $top),
'status_codes' => $this->topBy($entries, 'status_code', $top),
'slowest_requests' => $this->slowestRequests($entries, $limit),
'query_heavy_requests' => $this->queryHeavyRequests($entries, $limit),
'slow_queries' => $slowQueries,
'explain_plans' => $this->explainPlans($slowQueries),
'entries' => $entries->take($limit)->values()->all(),
'files' => $files,
];
}
/**
* @param list<string>|null $paths
* @return list<string>
*/
private function files(?array $paths): array
{
if ($paths !== null && $paths !== []) {
return collect($paths)
->flatMap(fn (string $path): array => glob($path) ?: [$path])
->filter(fn (string $path): bool => File::isFile($path) && File::isReadable($path))
->unique()
->sort()
->values()
->all();
}
$configuredPath = (string) config('logging.channels.admin_slow.path', storage_path('logs/admin-slow.log'));
$directory = dirname($configuredPath);
$basename = pathinfo($configuredPath, PATHINFO_FILENAME);
return collect(glob($directory.'/'.$basename.'*.log') ?: [])
->filter(fn (string $path): bool => File::isFile($path) && File::isReadable($path))
->sortDesc()
->values()
->all();
}
/**
* @param list<string> $files
* @return Collection<int, array<string, mixed>>
*/
private function entries(array $files): Collection
{
return collect($files)
->flatMap(function (string $file): array {
return collect(File::lines($file))
->map(fn (string $line): ?array => $this->parseLine($line, $file))
->filter()
->values()
->all();
})
->values();
}
/**
* @return array<string, mixed>|null
*/
private function parseLine(string $line, string $file): ?array
{
if (! str_contains($line, 'Slow admin request detected.')) {
return null;
}
if (! preg_match('/^\[(?<timestamp>[^\]]+)\]\s+(?<environment>[^.]+)\.(?<level>[^:]+):\s+Slow admin request detected\.\s+(?<context>\{.*\})\s*$/', $line, $matches)) {
return null;
}
$context = json_decode($matches['context'], true);
if (! is_array($context)) {
return null;
}
$timestamp = CarbonImmutable::parse($matches['timestamp']);
return [
'timestamp' => $timestamp->toDateTimeString(),
'environment' => $matches['environment'],
'level' => strtolower($matches['level']),
'file' => $file,
'method' => (string) ($context['method'] ?? ''),
'path' => (string) ($context['path'] ?? ''),
'route_name' => (string) ($context['route_name'] ?? 'unknown'),
'status_code' => (int) ($context['status_code'] ?? 0),
'user_id' => isset($context['user_id']) ? (int) $context['user_id'] : null,
'duration_ms' => (int) ($context['duration_ms'] ?? 0),
'database_time_ms' => (float) ($context['database_time_ms'] ?? 0),
'query_count' => (int) ($context['query_count'] ?? 0),
'slow_queries' => is_array($context['slow_queries'] ?? null) ? $context['slow_queries'] : [],
];
}
/**
* @param array<string, mixed> $entry
* @param array<string, mixed> $filters
*/
private function matchesFilters(array $entry, array $filters): bool
{
$timestamp = CarbonImmutable::parse((string) $entry['timestamp']);
if (filled($filters['from'] ?? null) && $timestamp->lt(CarbonImmutable::parse((string) $filters['from']))) {
return false;
}
if (filled($filters['to'] ?? null) && $timestamp->gt(CarbonImmutable::parse((string) $filters['to']))) {
return false;
}
if (filled($filters['route'] ?? null) && ! Str::contains((string) $entry['route_name'], (string) $filters['route'], true)) {
return false;
}
if (filled($filters['path'] ?? null) && ! Str::contains((string) $entry['path'], (string) $filters['path'], true)) {
return false;
}
if (($filters['status'] ?? null) !== null && (int) $entry['status_code'] !== (int) $filters['status']) {
return false;
}
if (($filters['min_duration_ms'] ?? null) !== null && (int) $entry['duration_ms'] < (int) $filters['min_duration_ms']) {
return false;
}
return true;
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @param list<string> $files
* @return array<string, int|float|string|null>
*/
private function summary(Collection $entries, array $files): array
{
return [
'files' => count($files),
'total_requests' => $entries->count(),
'unique_routes' => $entries->pluck('route_name')->filter()->unique()->count(),
'average_duration_ms' => round((float) $entries->avg('duration_ms'), 2),
'max_duration_ms' => (int) $entries->max('duration_ms'),
'average_database_time_ms' => round((float) $entries->avg('database_time_ms'), 2),
'max_database_time_ms' => round((float) $entries->max('database_time_ms'), 2),
'max_query_count' => (int) $entries->max('query_count'),
'period_from' => $entries->min('timestamp'),
'period_to' => $entries->max('timestamp'),
];
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function topBy(Collection $entries, string $key, int $top): array
{
return $entries
->groupBy(fn (array $entry): string => (string) ($entry[$key] ?: 'unknown'))
->map(fn (Collection $group, string $value): array => [
'value' => $value,
'requests' => $group->count(),
'average_duration_ms' => round((float) $group->avg('duration_ms'), 2),
'max_duration_ms' => (int) $group->max('duration_ms'),
'average_database_time_ms' => round((float) $group->avg('database_time_ms'), 2),
'total_queries' => (int) $group->sum('query_count'),
])
->sortByDesc('requests')
->take($top)
->values()
->all();
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function slowestRequests(Collection $entries, int $limit): array
{
return $entries
->sortByDesc('duration_ms')
->take($limit)
->values()
->all();
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function queryHeavyRequests(Collection $entries, int $limit): array
{
return $entries
->sortByDesc('query_count')
->take($limit)
->values()
->all();
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function slowQueries(Collection $entries, int $top): array
{
return $entries
->flatMap(fn (array $entry): array => $entry['slow_queries'])
->filter(fn (mixed $query): bool => is_array($query) && isset($query['sql']))
->groupBy(fn (array $query): string => (string) $query['sql'])
->map(fn (Collection $group, string $sql): array => [
'sql' => $sql,
'occurrences' => $group->count(),
'max_time_ms' => round((float) $group->max('time_ms'), 2),
'average_time_ms' => round((float) $group->avg('time_ms'), 2),
'connection' => $group->pluck('connection')->filter()->first(),
])
->sortByDesc('occurrences')
->take($top)
->values()
->all();
}
/**
* @param list<array<string, mixed>> $slowQueries
* @return list<array{sql: string, explainable: bool, plan: list<array<string, mixed>>, error: ?string}>
*/
private function explainPlans(array $slowQueries): array
{
return collect($slowQueries)
->map(function (array $query): array {
$sql = trim((string) $query['sql']);
if (! $this->isExplainable($sql)) {
return [
'sql' => $sql,
'explainable' => false,
'plan' => [],
'error' => 'Nur SELECT/CTE-Queries werden automatisch erklärt.',
];
}
try {
$bindings = array_fill(0, substr_count($sql, '?'), null);
$prefix = DB::connection()->getDriverName() === 'sqlite'
? 'EXPLAIN QUERY PLAN '
: 'EXPLAIN ';
return [
'sql' => $sql,
'explainable' => true,
'plan' => array_map(
static fn (object $row): array => (array) $row,
DB::select($prefix.$sql, $bindings),
),
'error' => null,
];
} catch (Throwable $exception) {
return [
'sql' => $sql,
'explainable' => true,
'plan' => [],
'error' => $exception->getMessage(),
];
}
})
->values()
->all();
}
private function isExplainable(string $sql): bool
{
return preg_match('/^\s*(select|with)\b/i', $sql) === 1;
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Services\Api;
use App\Enums\UserPaymentOptionStatus;
use App\Models\User;
class ApiAccessEligibilityService
{
public function canCreateToken(User $user): bool
{
return $this->denialReason($user) === null;
}
public function denialReason(User $user): ?string
{
if (! $user->is_active) {
return 'Ihr Benutzerkonto ist nicht aktiv. API-Tokens können nur für aktive Benutzer erstellt werden.';
}
if ($this->hasActivePaymentOption($user) || $this->hasPaidLegacyInvoice($user)) {
return null;
}
return 'API-Tokens werden erst freigeschaltet, wenn ein aktiver Zahlungsstatus oder freigegebener Bestandsschutz vorliegt.';
}
private function hasActivePaymentOption(User $user): bool
{
return $user->userPaymentOptions()
->where(function ($query): void {
$query
->where(function ($active): void {
$active
->where('status', UserPaymentOptionStatus::Active->value)
->whereDate('current_period_start', '<=', now())
->whereDate('current_period_end', '>=', now());
})
->orWhere(function ($grandfathered): void {
$grandfathered
->where('status', UserPaymentOptionStatus::Grandfathered->value)
->whereDate('grandfathered_until', '>=', now());
});
})
->exists();
}
private function hasPaidLegacyInvoice(User $user): bool
{
$latestInvoice = $user->legacyInvoices()
->latest('invoice_date')
->latest('id')
->first(['id', 'status']);
return $latestInvoice?->status === 'paid';
}
}

View file

@ -0,0 +1,106 @@
<?php
namespace App\Services\Api;
use App\Models\ApiUsageLog;
use Illuminate\Database\Eloquent\Builder;
class ApiUsageReporter
{
/**
* @return array<string, mixed>
*/
public function report(?string $from = null, ?string $to = null, ?int $userId = null, ?int $statusCode = null, int $top = 10): array
{
$baseQuery = $this->baseQuery($from, $to, $userId, $statusCode);
$totalRequests = (clone $baseQuery)->count();
return [
'generated_at' => now()->toIso8601String(),
'filters' => [
'from' => $from,
'to' => $to,
'user_id' => $userId,
'status_code' => $statusCode,
'top' => $top,
],
'summary' => [
'total_requests' => $totalRequests,
'unique_users' => (clone $baseQuery)->whereNotNull('user_id')->distinct('user_id')->count('user_id'),
'unique_tokens' => (clone $baseQuery)->whereNotNull('personal_access_token_id')->distinct('personal_access_token_id')->count('personal_access_token_id'),
'successful_requests' => (clone $baseQuery)->whereBetween('status_code', [200, 299])->count(),
'client_error_requests' => (clone $baseQuery)->whereBetween('status_code', [400, 499])->count(),
'server_error_requests' => (clone $baseQuery)->whereBetween('status_code', [500, 599])->count(),
'average_duration_ms' => $totalRequests > 0 ? round((float) (clone $baseQuery)->avg('duration_ms'), 2) : 0.0,
],
'top_paths' => $this->topRows($baseQuery, 'path', $top),
'status_codes' => $this->topRows($baseQuery, 'status_code', $top),
'top_users' => $this->topRows($baseQuery, 'user_id', $top),
'top_tokens' => $this->topRows($baseQuery, 'personal_access_token_id', $top),
'recent_requests' => $this->recentRequests($baseQuery, $top),
];
}
private function baseQuery(?string $from, ?string $to, ?int $userId, ?int $statusCode): Builder
{
return ApiUsageLog::query()
->when($from !== null, fn (Builder $query) => $query->where('requested_at', '>=', $from))
->when($to !== null, fn (Builder $query) => $query->where('requested_at', '<=', $to))
->when($userId !== null, fn (Builder $query) => $query->where('user_id', $userId))
->when($statusCode !== null, fn (Builder $query) => $query->where('status_code', $statusCode));
}
/**
* @return list<array{value: mixed, requests: int}>
*/
private function topRows(Builder $baseQuery, string $column, int $limit): array
{
return (clone $baseQuery)
->selectRaw("{$column} as value, count(*) as requests")
->whereNotNull($column)
->groupBy($column)
->orderByDesc('requests')
->limit($limit)
->get()
->map(fn ($row): array => [
'value' => $row->value,
'requests' => (int) $row->requests,
])
->all();
}
/**
* @return list<array<string, mixed>>
*/
private function recentRequests(Builder $baseQuery, int $limit): array
{
return (clone $baseQuery)
->latest('requested_at')
->latest('id')
->limit($limit)
->get([
'id',
'user_id',
'personal_access_token_id',
'method',
'path',
'route_name',
'status_code',
'duration_ms',
'requested_at',
])
->map(fn (ApiUsageLog $log): array => [
'id' => $log->id,
'user_id' => $log->user_id,
'personal_access_token_id' => $log->personal_access_token_id,
'method' => $log->method,
'path' => $log->path,
'route_name' => $log->route_name,
'status_code' => $log->status_code,
'duration_ms' => $log->duration_ms,
'requested_at' => $log->requested_at?->toIso8601String(),
])
->all();
}
}

View file

@ -0,0 +1,184 @@
<?php
namespace App\Services\Api;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class LegacyApiAccessLogAnalyzer
{
/**
* @param list<string> $paths
* @return array<string, mixed>
*/
public function analyze(array $paths, int $top = 20): array
{
$report = [
'generated_at' => now()->toIso8601String(),
'input_paths' => $paths,
'missing_paths' => [],
'summary' => [
'files' => 0,
'total_lines' => 0,
'matched_requests' => 0,
'legacy_key_requests' => 0,
'unique_client_ips' => 0,
'unique_api_key_fingerprints' => 0,
],
'endpoints' => [],
'client_ips' => [],
'api_key_fingerprints' => [],
'status_codes' => [],
'sample_requests' => [],
];
foreach ($this->expandPaths($paths) as $path) {
if (! is_readable($path)) {
$report['missing_paths'][] = $path;
continue;
}
$report['summary']['files']++;
$file = new \SplFileObject($path);
while (! $file->eof()) {
$line = trim((string) $file->fgets());
if ($line === '') {
continue;
}
$report['summary']['total_lines']++;
$request = $this->parseLine($line);
if ($request === null) {
continue;
}
$endpoint = $this->legacyEndpoint($request['path']);
if ($endpoint === null) {
continue;
}
$this->recordMatch($report, $request, $endpoint);
}
}
$report['summary']['unique_client_ips'] = count($report['client_ips']);
$report['summary']['unique_api_key_fingerprints'] = count($report['api_key_fingerprints']);
$this->sortAndLimit($report, $top);
return $report;
}
/**
* @param list<string> $paths
* @return list<string>
*/
private function expandPaths(array $paths): array
{
$expanded = [];
foreach ($paths as $path) {
$matches = Str::contains($path, ['*', '?', '['])
? glob($path) ?: []
: [$path];
foreach ($matches as $match) {
$expanded[] = $match;
}
}
return array_values(array_unique($expanded));
}
/**
* @return array{client_ip: string, method: string, target: string, path: string, query: string, status: string|null}|null
*/
private function parseLine(string $line): ?array
{
if (! preg_match('/^(?P<client_ip>\S+).*"(?P<method>GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(?P<target>\S+)[^"]*"\s+(?P<status>\d{3}|-)?/', $line, $matches)) {
return null;
}
$target = $matches['target'];
$parsedUrl = parse_url($target);
$path = Arr::get($parsedUrl, 'path', Str::before($target, '?'));
$query = Arr::get($parsedUrl, 'query', Str::contains($target, '?') ? Str::after($target, '?') : '');
return [
'client_ip' => $matches['client_ip'],
'method' => $matches['method'],
'target' => $target,
'path' => $path,
'query' => $query,
'status' => $matches['status'] !== '-' ? ($matches['status'] ?? null) : null,
];
}
private function legacyEndpoint(string $path): ?string
{
$normalizedPath = Str::of($path)
->replace('/index.php', '')
->trim('/')
->toString();
if (! preg_match('~(?:^|/)(pressrelease|pressreleaseimage|company|category|newsletter)/([a-zA-Z0-9_-]+)~', $normalizedPath, $matches)) {
return null;
}
return "{$matches[1]}/{$matches[2]}";
}
/**
* @param array<string, mixed> $report
* @param array{client_ip: string, method: string, target: string, path: string, query: string, status: string|null} $request
*/
private function recordMatch(array &$report, array $request, string $endpoint): void
{
$report['summary']['matched_requests']++;
$report['endpoints'][$endpoint] = ($report['endpoints'][$endpoint] ?? 0) + 1;
$report['client_ips'][$request['client_ip']] = ($report['client_ips'][$request['client_ip']] ?? 0) + 1;
if ($request['status'] !== null) {
$report['status_codes'][$request['status']] = ($report['status_codes'][$request['status']] ?? 0) + 1;
}
parse_str($request['query'], $query);
if (filled($query['api_key'] ?? null)) {
$report['summary']['legacy_key_requests']++;
$fingerprint = hash('sha256', (string) $query['api_key']);
$report['api_key_fingerprints'][$fingerprint] = ($report['api_key_fingerprints'][$fingerprint] ?? 0) + 1;
}
if (count($report['sample_requests']) < 10) {
$report['sample_requests'][] = [
'method' => $request['method'],
'target' => $this->maskApiKey($request['target']),
'endpoint' => $endpoint,
'client_ip' => $request['client_ip'],
'status' => $request['status'],
];
}
}
private function maskApiKey(string $target): string
{
return preg_replace('/([?&]api_key=)[^&]+/', '$1***', $target) ?? $target;
}
/**
* @param array<string, mixed> $report
*/
private function sortAndLimit(array &$report, int $top): void
{
foreach (['endpoints', 'client_ips', 'api_key_fingerprints', 'status_codes'] as $key) {
arsort($report[$key]);
$report[$key] = array_slice($report[$key], 0, $top, true);
}
}
}

View file

@ -0,0 +1,143 @@
<?php
namespace App\Services\Api;
use App\Enums\RegistrationType;
use App\Models\LegacyInvoice;
use App\Models\User;
use Illuminate\Support\Collection;
class LegacyApiCustomerReporter
{
/**
* @return array<string, mixed>
*/
public function report(string $portal = 'all'): array
{
$users = $this->candidateUsers($portal);
$rows = $users
->map(fn (User $user): array => $this->mapUser($user))
->values();
return [
'generated_at' => now()->toIso8601String(),
'portal' => $portal,
'source' => [
'candidate_rule' => 'users.registration_type = apiuser OR Spatie role api-only',
'eligibility_rule' => 'user is active AND latest legacy invoice status is paid',
'missing_data' => [
'Legacy api_key values are intentionally not imported.',
'Legacy apiReadAccess/apiWriteAccess assignments are not preserved per-user beyond mapped roles.',
'Users without archived legacy invoices require manual review.',
],
],
'summary' => [
'total_candidates' => $rows->count(),
'eligible' => $rows->where('classification', 'eligible')->count(),
'needs_review' => $rows->where('classification', 'needs_review')->count(),
'blocked' => $rows->where('classification', 'blocked')->count(),
],
'customers' => $rows->all(),
];
}
/**
* @return Collection<int, User>
*/
private function candidateUsers(string $portal): Collection
{
return User::query()
->select([
'id',
'name',
'email',
'portal',
'registration_type',
'is_active',
'is_super_admin',
'legacy_portal',
'legacy_id',
'last_login_at',
])
->with([
'roles:id,name',
'legacyInvoices' => fn ($query) => $query
->select([
'id',
'user_id',
'legacy_portal',
'legacy_id',
'number',
'status',
'invoice_date',
'paid_at',
'total_cents',
])
->latest('invoice_date')
->latest('id'),
])
->where(function ($query): void {
$query->where('registration_type', RegistrationType::ApiUser->value)
->orWhereHas('roles', fn ($roles) => $roles->where('name', 'api-only'));
})
->when($portal !== 'all', fn ($query) => $query->where('portal', $portal))
->orderBy('email')
->get();
}
/**
* @return array<string, mixed>
*/
private function mapUser(User $user): array
{
$latestInvoice = $user->legacyInvoices->first();
$classification = $this->classification($user, $latestInvoice);
return [
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'portal' => $user->portal?->value,
'legacy' => [
'portal' => $user->legacy_portal,
'id' => $user->legacy_id,
],
'registration_type' => $user->registration_type?->value,
'roles' => $user->roles->pluck('name')->values()->all(),
'is_active' => $user->is_active,
'is_super_admin' => $user->is_super_admin,
'last_login_at' => $user->last_login_at?->toDateTimeString(),
'latest_legacy_invoice' => $latestInvoice ? [
'id' => $latestInvoice->id,
'legacy' => [
'portal' => $latestInvoice->legacy_portal?->value,
'id' => $latestInvoice->legacy_id,
],
'number' => $latestInvoice->number,
'status' => $latestInvoice->status,
'invoice_date' => $latestInvoice->invoice_date?->toDateString(),
'paid_at' => $latestInvoice->paid_at?->toDateTimeString(),
'total_cents' => $latestInvoice->total_cents,
] : null,
'classification' => $classification,
'recommended_action' => match ($classification) {
'eligible' => 'invite_to_generate_sanctum_token',
'needs_review' => 'manual_billing_review_required',
default => 'do_not_grant_api_access',
},
];
}
private function classification(User $user, ?LegacyInvoice $latestInvoice): string
{
if (! $user->is_active) {
return 'blocked';
}
if ($latestInvoice === null) {
return 'needs_review';
}
return $latestInvoice->status === 'paid' ? 'eligible' : 'blocked';
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Services\Auth;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class MagicLinkGenerator
{
/**
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
*/
public function createLoginLink(User $user, ?string $requestedIp = null, int $ttlMinutes = 15): array
{
$plainToken = Str::random(64);
$tokenHash = hash('sha256', $plainToken);
$expiresAt = now()->addMinutes($ttlMinutes);
MagicLink::query()
->where('user_id', $user->id)
->where('purpose', 'login')
->whereNull('consumed_at')
->update([
'consumed_at' => now(),
'ip_consumed' => $requestedIp,
]);
$magicLink = $user->magicLinks()->create([
'token_hash' => $tokenHash,
'purpose' => 'login',
'expires_at' => $expiresAt,
'ip_requested' => $requestedIp,
]);
return [
'magic_link' => $magicLink,
'plain_token' => $plainToken,
'expires_at' => $expiresAt,
];
}
/**
* Public read-only share link for a press release. Token holder can view
* the press release in any state (draft/review/rejected/published) until
* the link expires. Default TTL: 7 days.
*
* @return array{magic_link: MagicLink, plain_token: string, url: string, expires_at: Carbon}
*/
public function createPressReleaseShareLink(PressRelease $pressRelease, ?User $issuer = null, int $ttlDays = 7): array
{
$plainToken = Str::random(64);
$tokenHash = hash('sha256', $plainToken);
$expiresAt = now()->addDays($ttlDays);
$magicLink = MagicLink::query()->create([
'user_id' => $issuer?->id ?? $pressRelease->user_id,
'token_hash' => $tokenHash,
'purpose' => 'press_release_access',
'payload' => [
'press_release_id' => $pressRelease->id,
],
'expires_at' => $expiresAt,
]);
return [
'magic_link' => $magicLink,
'plain_token' => $plainToken,
'url' => route('press-releases.preview', ['token' => $plainToken]),
'expires_at' => $expiresAt,
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
/**
* Zentraler Service für Rollen-Zuweisung an User.
*
* Rollen-Checks laufen ausschließlich über Spatie-Rollen (nicht über direkte Permissions).
* Dieser Service ist der Single Point of Truth für Rollen-Sync im Seeder und beim Legacy-Import.
*/
class UserRolePermissionSyncService
{
public function assignRoleAndSyncPermissions(User $user, string $role): void
{
$user->syncRoles([$role]);
}
}

View file

@ -0,0 +1,193 @@
<?php
namespace App\Services\Billing;
use App\Models\LegacyInvoice;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class LegacyInvoicePdfRenderer
{
public function inlineResponse(LegacyInvoice $invoice): Response
{
$pdf = $this->render($invoice);
return response($pdf, Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$this->filename($invoice).'"',
'Cache-Control' => 'private, max-age=0, must-revalidate',
]);
}
public function downloadResponse(LegacyInvoice $invoice): Response
{
$pdf = $this->render($invoice);
return response()->streamDownload(
static function () use ($pdf): void {
echo $pdf;
},
$this->filename($invoice),
[
'Content-Type' => 'application/pdf',
'Cache-Control' => 'private, max-age=0, must-revalidate',
],
);
}
public function render(LegacyInvoice $invoice): string
{
$lines = $this->lines($invoice);
$content = "BT\n/F1 11 Tf\n50 790 Td\n14 TL\n";
foreach ($lines as $line) {
$content .= '('.$this->escapePdfText($line).") Tj\nT*\n";
}
$content .= "ET\n";
return $this->buildPdf($content);
}
public function filename(LegacyInvoice $invoice): string
{
$number = filled($invoice->number) ? (string) $invoice->number : (string) $invoice->legacy_id;
$number = preg_replace('/[^A-Za-z0-9._-]/', '-', Str::ascii($number)) ?: (string) $invoice->id;
$portal = preg_replace('/[^A-Za-z0-9._-]/', '-', Str::ascii($invoice->legacy_portal->label()));
return "{$portal}-RNr-{$number}.pdf";
}
/**
* @return list<string>
*/
private function lines(LegacyInvoice $invoice): array
{
$payload = $invoice->pdf_payload ?? [];
$billingAddress = data_get($payload, 'billing_address', []);
$invoiceData = data_get($payload, 'invoice', $invoice->raw_snapshot ?? []);
$invoice->loadMissing('user.profile');
$isNetto = (bool) data_get($invoiceData, 'is_netto', false);
$taxPercent = $this->taxPercent($invoice);
$amount = $invoice->total_cents / 100;
$netAmount = $isNetto ? $amount : $amount / (1 + ($taxPercent / 100));
$taxAmount = $isNetto ? 0 : $amount - $netAmount;
$servicePeriodBegin = $this->formatLegacyDate(data_get($invoiceData, 'service_period_begin_date'));
$servicePeriodEnd = $this->formatLegacyDate(data_get($invoiceData, 'service_period_end_date'));
$serviceName = data_get($payload, 'payment_option_translation.name')
?? data_get($payload, 'payment_option.article_number')
?? 'Legacy-Leistung';
return array_values(array_filter([
$invoice->legacy_portal->label(),
'adametz.media, Kevin Adametz, In der Lake 4, 33739 Bielefeld',
'www.businessportal24.com',
str_repeat('-', 68),
'',
'Legacy-Rechnung',
'Rechnungsdatum: '.$invoice->invoice_date?->format('d.m.Y'),
'',
'Rechnungsadresse',
data_get($billingAddress, 'name'),
data_get($billingAddress, 'title'),
data_get($billingAddress, 'address'),
trim((string) data_get($billingAddress, 'postal_code').' '.(string) data_get($billingAddress, 'city')),
data_get($billingAddress, 'country_name'),
$invoice->user?->profile?->tax_id_number ? 'UID-Nr.: '.$invoice->user->profile->tax_id_number : null,
'',
'Leistung: '.$serviceName.' auf '.$invoice->legacy_portal->label(),
$servicePeriodBegin === $servicePeriodEnd
? 'Leistungsdatum: '.$servicePeriodBegin
: 'Leistungszeitraum: '.$servicePeriodBegin.' - '.$servicePeriodEnd,
'Rechnungsnummer: '.($invoice->number ?? '#'.$invoice->legacy_id),
'',
str_repeat('-', 68),
'Rechnungsstellung: '.$invoice->invoice_date?->format('d.m.Y'),
str_repeat('-', 68),
'Netto: '.$this->formatEuro($netAmount),
$isNetto ? null : 'MwSt. '.$taxPercent.'%: '.$this->formatEuro($taxAmount),
str_repeat('-', 68),
'Betrag: '.$this->formatEuro($amount),
str_repeat('-', 68),
'',
'Zahlart: '.($invoice->payment_method ?? 'n/a'),
'Status: '.($invoice->status ?? 'unknown'),
$invoice->paid_at ? 'Bezahlt am: '.$invoice->paid_at->format('d.m.Y') : 'Faellig am: '.$invoice->due_date?->format('d.m.Y'),
'',
'Bitte ueberweisen Sie den Rechnungsbetrag unter Angabe der Rechnungsnummer '.($invoice->number ?? '#'.$invoice->legacy_id).'.',
'Bankverbindung: Sparkasse Bielefeld, IBAN DE96 4805 0161 0065 0356 02, BIC SPBIDE3BXXX',
$isNetto ? 'Reverse Charge: Steuerschuldnerschaft des Leistungsempfaengers.' : null,
'',
str_repeat('-', 68),
'adametz.media | Tel: +49 5206 7076721 | Mail: info@businessportal24.com',
'Steuernummer: 349 / 5001 / 4350 | USt-ID: DE298729654',
], fn (mixed $line): bool => $line !== null && $line !== ''));
}
private function taxPercent(LegacyInvoice $invoice): int
{
$invoiceDate = $invoice->invoice_date;
if ($invoiceDate && $invoiceDate->betweenIncluded(Carbon::parse('2020-07-01'), Carbon::parse('2020-12-31'))) {
return 16;
}
return 19;
}
private function formatLegacyDate(mixed $value): string
{
if (blank($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') {
return 'n/a';
}
return Carbon::parse((string) $value)->format('d.m.Y');
}
private function formatEuro(float $amount): string
{
return number_format($amount, 2, ',', '.').' EUR';
}
private function buildPdf(string $content): string
{
$objects = [
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n",
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n",
"5 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
];
$pdf = "%PDF-1.4\n";
$offsets = [0];
foreach ($objects as $object) {
$offsets[] = strlen($pdf);
$pdf .= $object;
}
$xrefOffset = strlen($pdf);
$pdf .= "xref\n0 ".(count($objects) + 1)."\n";
$pdf .= "0000000000 65535 f \n";
foreach (array_slice($offsets, 1) as $offset) {
$pdf .= sprintf("%010d 00000 n \n", $offset);
}
$pdf .= "trailer\n<< /Size ".(count($objects) + 1)." /Root 1 0 R >>\n";
$pdf .= "startxref\n{$xrefOffset}\n%%EOF\n";
return $pdf;
}
private function escapePdfText(string $text): string
{
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
}
}

View file

@ -0,0 +1,213 @@
<?php
namespace App\Services;
class CategoryService
{
/**
* Alle verfügbaren Kategorien
*/
public static function getCategories(): array
{
return [
[
'name' => 'Wirtschaft',
'slug' => 'wirtschaft',
'description' => 'Unternehmensnachrichten, Finanzberichte, Wirtschaftstrends',
'count' => '2.450+',
'icon' => 'chart-bar',
'color' => 'blue',
],
[
'name' => 'Technologie',
'slug' => 'technologie',
'description' => 'IT, Software, Digitalisierung, Innovation',
'count' => '1.890+',
'icon' => 'cpu-chip',
'color' => 'purple',
],
[
'name' => 'Gesundheit',
'slug' => 'gesundheit',
'description' => 'Medizin, Pharma, Gesundheitswesen, Forschung',
'count' => '1.340+',
'icon' => 'heart',
'color' => 'green',
],
[
'name' => 'Finanzen',
'slug' => 'finanzen',
'description' => 'Banking, Investment, Fintech, Versicherungen',
'count' => '980+',
'icon' => 'currency-dollar',
'color' => 'yellow',
],
[
'name' => 'Automotive',
'slug' => 'automotive',
'description' => 'Fahrzeuge, E-Mobilität, Zulieferer, Logistik',
'count' => '750+',
'icon' => 'truck',
'color' => 'red',
],
[
'name' => 'Immobilien',
'slug' => 'immobilien',
'description' => 'Bau, Entwicklung, PropTech, Gewerbe',
'count' => '640+',
'icon' => 'building-office',
'color' => 'indigo',
],
[
'name' => 'Energie',
'slug' => 'energie',
'description' => 'Erneuerbare Energien, Strom, Gas, Nachhaltigkeit',
'count' => '520+',
'icon' => 'bolt',
'color' => 'orange',
],
[
'name' => 'Bildung',
'slug' => 'bildung',
'description' => 'Schulen, Universitäten, E-Learning, Weiterbildung',
'count' => '480+',
'icon' => 'academic-cap',
'color' => 'cyan',
],
[
'name' => 'Handel',
'slug' => 'handel',
'description' => 'Einzelhandel, E-Commerce, Konsumgüter',
'count' => '410+',
'icon' => 'shopping-cart',
'color' => 'pink',
],
[
'name' => 'Tourismus',
'slug' => 'tourismus',
'description' => 'Reisen, Hotellerie, Gastronomie, Veranstaltungen',
'count' => '390+',
'icon' => 'globe-alt',
'color' => 'teal',
],
[
'name' => 'Sport',
'slug' => 'sport',
'description' => 'Sportveranstaltungen, Vereine, Fitness, Sponsoring',
'count' => '320+',
'icon' => 'trophy',
'color' => 'lime',
],
[
'name' => 'Kultur',
'slug' => 'kultur',
'description' => 'Kunst, Musik, Theater, Medien, Entertainment',
'count' => '290+',
'icon' => 'musical-note',
'color' => 'violet',
],
];
}
/**
* Kategorie nach Slug finden
*/
public static function getCategoryBySlug(string $slug): ?array
{
$categories = self::getCategories();
foreach ($categories as $category) {
if ($category['slug'] === $slug) {
return $category;
}
}
return null;
}
/**
* Alle Kategorie-Slugs abrufen
*/
public static function getCategorySlugs(): array
{
return array_column(self::getCategories(), 'slug');
}
/**
* Farb-Gradient Mapping
*/
public static function getColorGradients(): array
{
return [
'blue' => 'from-blue-500/10 to-blue-600/10',
'purple' => 'from-purple-500/10 to-purple-600/10',
'green' => 'from-green-500/10 to-green-600/10',
'yellow' => 'from-yellow-500/10 to-yellow-600/10',
'red' => 'from-red-500/10 to-red-600/10',
'indigo' => 'from-indigo-500/10 to-indigo-600/10',
'orange' => 'from-orange-500/10 to-orange-600/10',
'cyan' => 'from-cyan-500/10 to-cyan-600/10',
'pink' => 'from-pink-500/10 to-pink-600/10',
'teal' => 'from-teal-500/10 to-teal-600/10',
'lime' => 'from-lime-500/10 to-lime-600/10',
'violet' => 'from-violet-500/10 to-violet-600/10',
];
}
/**
* Farb-Klassen Mapping
*/
public static function getColorClasses(): array
{
return [
'blue' => 'text-blue-500',
'purple' => 'text-purple-500',
'green' => 'text-green-500',
'yellow' => 'text-yellow-600',
'red' => 'text-red-500',
'indigo' => 'text-indigo-500',
'orange' => 'text-orange-500',
'cyan' => 'text-cyan-500',
'pink' => 'text-pink-500',
'teal' => 'text-teal-500',
'lime' => 'text-lime-500',
'violet' => 'text-violet-500',
];
}
/**
* Gradient für eine Farbe abrufen
*/
public static function getGradientForColor(string $color): string
{
$gradients = self::getColorGradients();
return $gradients[$color] ?? 'from-gray-500/10 to-gray-600/10';
}
/**
* CSS-Klasse für eine Farbe abrufen
*/
public static function getClassForColor(string $color): string
{
$classes = self::getColorClasses();
return $classes[$color] ?? 'text-gray-500';
}
/**
* Icon-Pfad für eine Kategorie generieren
*/
public static function getIconPath(string $iconName): string
{
return "/heroicons/optimized/24/outline/{$iconName}.svg";
}
/**
* Gesamtanzahl der Kategorien
*/
public static function count(): int
{
return count(self::getCategories());
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Services;
use App\Enums\Portal;
/**
* Hält den aktiven Portal-Kontext für den aktuellen Request.
*
* Wird vom SetCurrentPortal-Middleware gesetzt und von PortalScope gelesen.
* In CLI-Befehlen und Queue-Jobs bleibt der Kontext null kein Filter.
*/
class CurrentPortalContext
{
private static ?Portal $portal = null;
public static function set(?Portal $portal): void
{
static::$portal = $portal;
}
public static function get(): ?Portal
{
return static::$portal;
}
public static function isActive(): bool
{
return static::$portal !== null;
}
public static function clear(): void
{
static::$portal = null;
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace App\Services\Customer;
use App\Models\Company;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class CustomerCompanyContext
{
public const SessionKey = 'customer_company_context_id';
/**
* @return Collection<int, Company>
*/
public function companiesFor(User $user): Collection
{
$linkedCompanies = $user->companies()
->withoutGlobalScopes()
->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id'])
->withPivot('role')
->get();
$ownedCompanies = $user->ownedCompanies()
->withoutGlobalScopes()
->whereNotIn('id', $linkedCompanies->pluck('id'))
->get(['id', 'name', 'portal', 'owner_user_id']);
return $linkedCompanies
->concat($ownedCompanies)
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
->values();
}
public function selectedCompanyId(User $user): ?int
{
$companyId = session(self::SessionKey);
if (! is_numeric($companyId)) {
return null;
}
$companyId = (int) $companyId;
if (! $this->userCanAccessCompany($user, $companyId)) {
$this->clear();
return null;
}
return $companyId;
}
public function selectedCompany(User $user): ?Company
{
$companyId = $this->selectedCompanyId($user);
if ($companyId === null) {
return null;
}
return $this->companiesFor($user)->firstWhere('id', $companyId);
}
/**
* @return Builder<Company>
*/
public function accessibleCompanyQuery(User $user): Builder
{
return Company::query()
->withoutGlobalScopes()
->where(function ($query) use ($user): void {
$query->where('owner_user_id', $user->id)
->orWhereHas('users', fn ($userQuery) => $userQuery->whereKey($user->id));
});
}
public function findFor(User $user, int $companyId): ?Company
{
return $this->accessibleCompanyQuery($user)
->whereKey($companyId)
->first();
}
public function select(User $user, ?int $companyId): void
{
if ($companyId === null) {
$this->clear();
return;
}
if (! $this->userCanAccessCompany($user, $companyId)) {
return;
}
session([self::SessionKey => $companyId]);
}
public function clear(): void
{
session()->forget(self::SessionKey);
}
public function roleLabelFor(Company $company, User $user): string
{
$role = $company->owner_user_id === $user->id
? 'owner'
: ($company->pivot?->role ?? 'member');
return match ($role) {
'owner' => __('Owner'),
'responsible' => __('Verantwortlich'),
default => __('Mitglied'),
};
}
private function userCanAccessCompany(User $user, int $companyId): bool
{
return $this->accessibleCompanyQuery($user)
->whereKey($companyId)
->exists();
}
}

View file

@ -0,0 +1,416 @@
<?php
namespace App\Services\Image;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Centralised image processing for company logos and press release images.
*
* Uses GD natively to keep the dependency footprint small. Should
* `intervention/image` be added later, only the private rendering
* helpers below need to be replaced.
*
* Generates the logo variant set defined by the CRM data model:
* - sq: 1:1 contained, 256x256
* - wide: 2:1 contained, 640x320
*
* Each variant is written next to the original and registered in
* `companies.logo_variants` (JSON).
*/
class ImageService
{
/**
* @var array<string, array{width: int, height: int}>
*/
public const LOGO_VARIANTS = [
'sq' => ['width' => 256, 'height' => 256],
'wide' => ['width' => 640, 'height' => 320],
];
/**
* Press release image variants. The `large` variant is the canonical
* full-size representation we serve in the new portal; `thumb` and
* `medium` are derived for listings and previews.
*
* @var array<string, array{width: int, height: int, fit?: string}>
*/
public const PRESS_RELEASE_IMAGE_VARIANTS = [
'thumb' => ['width' => 320, 'height' => 240],
'medium' => ['width' => 800, 'height' => 600],
'large' => ['width' => 1600, 'height' => 1200],
];
public const ALLOWED_LOGO_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
];
public const ALLOWED_PRESS_RELEASE_IMAGE_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
public const MAX_LOGO_BYTES = 4 * 1024 * 1024; // 4 MB
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB
public function __construct(private readonly string $disk = 'public') {}
/**
* Persists a freshly uploaded company logo and generates all variants.
*
* @return array{path: string, variants: array<string, string>}
*/
public function storeCompanyLogo(UploadedFile $upload, string $portal, int $companyId): array
{
$this->assertValidLogoUpload($upload);
$directory = sprintf('company-logos/%s/%d', $portal, $companyId);
$extension = $this->normalizedExtension($upload->getMimeType());
$filename = Str::uuid()->toString().'.'.$extension;
$relativePath = $directory.'/'.$filename;
$disk = $this->disk();
$disk->put($relativePath, $upload->get(), 'public');
$variants = $this->generateLogoVariants($disk, $relativePath, $extension);
return [
'path' => $relativePath,
'variants' => $variants,
];
}
/**
* Deletes a logo and all variants below the given relative path.
*/
public function deleteCompanyLogo(?string $relativePath, ?array $variants): void
{
$this->deleteWithVariants($relativePath, $variants);
}
/**
* Persists a freshly uploaded press release image and generates all
* variants. Original is stored under `press-releases/{id}/images`.
*
* @return array{
* path: string,
* variants: array<string, string>,
* width: int|null,
* height: int|null,
* mime: string|null,
* }
*/
public function storePressReleaseImage(UploadedFile $upload, int $pressReleaseId): array
{
$this->assertValidPressReleaseImageUpload($upload);
$directory = sprintf('press-releases/%d/images', $pressReleaseId);
$extension = $this->normalizedExtension($upload->getMimeType());
$filename = Str::uuid()->toString().'.'.$extension;
$relativePath = $directory.'/'.$filename;
$disk = $this->disk();
$disk->put($relativePath, $upload->get(), 'public');
$absolute = $disk->path($relativePath);
$size = @getimagesize($absolute) ?: [null, null];
$variants = $this->generateVariants(
$disk,
$relativePath,
$extension,
self::PRESS_RELEASE_IMAGE_VARIANTS,
cover: true,
);
return [
'path' => $relativePath,
'variants' => $variants,
'width' => is_int($size[0] ?? null) ? $size[0] : null,
'height' => is_int($size[1] ?? null) ? $size[1] : null,
'mime' => $upload->getMimeType(),
];
}
/**
* Deletes a press release image and all variants on the given disk.
*
* @param array<string, string>|null $variants
*/
public function deletePressReleaseImage(string $disk, ?string $relativePath, ?array $variants): void
{
$this->deleteWithVariants($relativePath, $variants, $disk);
}
/**
* Generates and persists missing variants for an existing image (for
* legacy images that already live on disk). Returns the variant map.
*
* @return array<string, string>
*/
public function generateMissingPressReleaseVariants(string $relativePath): array
{
$disk = $this->disk();
if (! $disk->exists($relativePath)) {
return [];
}
$extension = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION) ?: 'jpg');
return $this->generateVariants(
$disk,
$relativePath,
$extension,
self::PRESS_RELEASE_IMAGE_VARIANTS,
cover: true,
);
}
private function deleteWithVariants(?string $relativePath, ?array $variants, ?string $diskName = null): void
{
$disk = $diskName ? Storage::disk($diskName) : $this->disk();
if (filled($relativePath) && $disk->exists($relativePath)) {
$disk->delete($relativePath);
}
if (is_array($variants)) {
foreach ($variants as $variantPath) {
if (is_string($variantPath) && $disk->exists($variantPath)) {
$disk->delete($variantPath);
}
}
}
}
/**
* @return array<string, string>
*/
private function generateLogoVariants(Filesystem $disk, string $relativePath, string $extension): array
{
return $this->generateVariants($disk, $relativePath, $extension, self::LOGO_VARIANTS, cover: false);
}
/**
* Generic variant generator. `cover` switches between contained
* (transparent letterbox, used for logos) and cover (cropped to fill,
* used for press release imagery).
*
* @param array<string, array{width: int, height: int}> $variantSpecs
* @return array<string, string>
*/
private function generateVariants(Filesystem $disk, string $relativePath, string $extension, array $variantSpecs, bool $cover = false): array
{
$absolute = $disk->path($relativePath);
$sourceImage = $this->createImageResource($absolute, $extension);
if (! $sourceImage) {
return [];
}
try {
$variants = [];
$sourceWidth = imagesx($sourceImage);
$sourceHeight = imagesy($sourceImage);
foreach ($variantSpecs as $key => $size) {
$variantPath = $this->variantPath($relativePath, $key);
$variantAbsolute = $disk->path($variantPath);
$disk->makeDirectory(dirname($variantPath));
$targetWidth = $size['width'];
$targetHeight = $size['height'];
$upscale = $sourceWidth < $targetWidth && $sourceHeight < $targetHeight;
if ($upscale) {
$scale = min($sourceWidth / $targetWidth, $sourceHeight / $targetHeight);
$targetWidth = max(1, (int) round($targetWidth * $scale));
$targetHeight = max(1, (int) round($targetHeight * $scale));
}
$resized = $cover
? $this->resizeCover($sourceImage, $targetWidth, $targetHeight)
: $this->resizeContained($sourceImage, $targetWidth, $targetHeight);
$written = $this->writeImage($resized, $variantAbsolute, $extension);
imagedestroy($resized);
if ($written) {
$variants[$key] = $variantPath;
}
}
return $variants;
} finally {
imagedestroy($sourceImage);
}
}
/**
* @return \GdImage|false
*/
private function createImageResource(string $absolutePath, string $extension)
{
return match ($extension) {
'jpg' => @imagecreatefromjpeg($absolutePath),
'png' => @imagecreatefrompng($absolutePath),
'webp' => @imagecreatefromwebp($absolutePath),
'gif' => @imagecreatefromgif($absolutePath),
default => false,
};
}
private function writeImage(\GdImage $image, string $absolutePath, string $extension): bool
{
return match ($extension) {
'jpg' => imagejpeg($image, $absolutePath, 88),
'png' => imagepng($image, $absolutePath, 6),
'webp' => imagewebp($image, $absolutePath, 88),
'gif' => imagegif($image, $absolutePath),
default => false,
};
}
/**
* Resizes preserving aspect ratio with transparent letterbox/pillarbox
* so logos always render fully on a square or 2:1 canvas.
*/
private function resizeContained(\GdImage $source, int $targetWidth, int $targetHeight): \GdImage
{
$sourceWidth = imagesx($source);
$sourceHeight = imagesy($source);
$scale = min($targetWidth / $sourceWidth, $targetHeight / $sourceHeight);
$resizedWidth = max(1, (int) round($sourceWidth * $scale));
$resizedHeight = max(1, (int) round($sourceHeight * $scale));
$offsetX = (int) round(($targetWidth - $resizedWidth) / 2);
$offsetY = (int) round(($targetHeight - $resizedHeight) / 2);
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
imagealphablending($canvas, true);
imagecopyresampled(
$canvas,
$source,
$offsetX,
$offsetY,
0,
0,
$resizedWidth,
$resizedHeight,
$sourceWidth,
$sourceHeight,
);
return $canvas;
}
/**
* Cover-resize: scale source so that the target box is fully filled, then
* crop the overflow centered. Used for press release imagery so listings
* always render rectangles without empty bars.
*/
private function resizeCover(\GdImage $source, int $targetWidth, int $targetHeight): \GdImage
{
$sourceWidth = imagesx($source);
$sourceHeight = imagesy($source);
$scale = max($targetWidth / $sourceWidth, $targetHeight / $sourceHeight);
$sampledWidth = max(1, (int) round($targetWidth / $scale));
$sampledHeight = max(1, (int) round($targetHeight / $scale));
$sourceX = (int) round(($sourceWidth - $sampledWidth) / 2);
$sourceY = (int) round(($sourceHeight - $sampledHeight) / 2);
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
imagealphablending($canvas, true);
imagecopyresampled(
$canvas,
$source,
0,
0,
$sourceX,
$sourceY,
$targetWidth,
$targetHeight,
$sampledWidth,
$sampledHeight,
);
return $canvas;
}
private function variantPath(string $originalRelativePath, string $variantKey): string
{
$info = pathinfo($originalRelativePath);
$filename = $info['filename'] ?? Str::uuid()->toString();
$extension = $info['extension'] ?? 'jpg';
return ($info['dirname'] ?? '').'/variants/'.$filename.'-'.$variantKey.'.'.$extension;
}
private function normalizedExtension(?string $mimeType): string
{
return match ($mimeType) {
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
'image/jpeg', 'image/jpg' => 'jpg',
default => 'jpg',
};
}
private function assertValidLogoUpload(UploadedFile $upload): void
{
if (! in_array($upload->getMimeType(), self::ALLOWED_LOGO_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported logo mime type: '.($upload->getMimeType() ?? 'unknown'));
}
if ($upload->getSize() > self::MAX_LOGO_BYTES) {
throw new RuntimeException('Logo exceeds maximum allowed size of '.self::MAX_LOGO_BYTES.' bytes.');
}
}
private function assertValidPressReleaseImageUpload(UploadedFile $upload): void
{
if (! in_array($upload->getMimeType(), self::ALLOWED_PRESS_RELEASE_IMAGE_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported press release image mime type: '.($upload->getMimeType() ?? 'unknown'));
}
if ($upload->getSize() > self::MAX_PRESS_RELEASE_IMAGE_BYTES) {
throw new RuntimeException('Press release image exceeds maximum allowed size of '.self::MAX_PRESS_RELEASE_IMAGE_BYTES.' bytes.');
}
}
private function disk(): Filesystem
{
return Storage::disk($this->disk);
}
}

View file

@ -0,0 +1,124 @@
<?php
namespace App\Services\Import;
use App\Models\Category;
use App\Models\LegacyImportMap;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Kategorien sind in beiden Portalen identisch (14 Stück, gleiche IDs).
* Nur einmalig importieren beim zweiten Portal überspringen wenn bereits vorhanden.
*/
class CategoryImporter
{
public function run(ImportContext $ctx): ImportResult
{
$result = new ImportResult;
$conn = $ctx->connection;
$legacyPortal = $ctx->legacyPortalValue();
// Alle Kategorien mit DE+EN-Translations laden
$categories = DB::connection($conn)
->table('category')
->orderBy('id')
->get();
$translations = DB::connection($conn)
->table('category_translation')
->whereIn('lang', ['de', 'de_AT', 'de_CH', 'en', 'en_CA', 'en_GB', 'en_US'])
->get()
->groupBy('id'); // id = category_id
foreach ($categories as $cat) {
try {
$this->importRow($cat, $ctx, $result, $legacyPortal, $translations->get($cat->id, collect()));
} catch (\Throwable $e) {
$result->addError("Category id={$cat->id}: {$e->getMessage()}");
}
}
return $result;
}
private function importRow(
object $cat,
ImportContext $ctx,
ImportResult $result,
string $legacyPortal,
Collection $trans,
): void {
// Kategorien sind portalübergreifend nur einmal importieren
$alreadyImported = LegacyImportMap::query()
->where('legacy_table', 'category')
->where('legacy_id', $cat->id)
->exists();
if ($alreadyImported && ! $ctx->force) {
$result->incrementSkipped();
return;
}
if ($ctx->dryRun) {
$result->incrementImported();
return;
}
$category = Category::query()->updateOrCreate(
['legacy_portal' => $legacyPortal, 'legacy_id' => $cat->id],
[
'portal' => 'both',
'is_active' => true,
]
);
// DE-Translation (erste passende)
$de = $trans->first(fn ($t) => str_starts_with($t->lang, 'de'));
// EN-Translation (erste passende)
$en = $trans->first(fn ($t) => str_starts_with($t->lang, 'en'));
if ($de) {
$category->translations()->updateOrCreate(
['locale' => 'de'],
[
'name' => $de->name,
'slug' => $de->slug,
'description' => $de->description ?: null,
]
);
}
if ($en) {
$category->translations()->updateOrCreate(
['locale' => 'en'],
[
'name' => $en->name,
'slug' => $en->slug,
'description' => $en->description ?: null,
]
);
}
LegacyImportMap::query()->updateOrCreate(
[
'legacy_portal' => $legacyPortal,
'legacy_table' => 'category',
'legacy_id' => $cat->id,
],
[
'target_table' => 'categories',
'target_id' => $category->id,
'imported_at' => now(),
]
);
if ($alreadyImported) {
$result->incrementUpdated();
} else {
$result->incrementImported();
}
}
}

View file

@ -0,0 +1,203 @@
<?php
namespace App\Services\Import;
use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\LegacyImportMap;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class CompanyImporter
{
private const CHUNK_SIZE = 500;
private const CTYPE_MAP = [
'agency' => CompanyType::Agency,
'company' => CompanyType::Company,
];
public function run(ImportContext $ctx): ImportResult
{
$result = new ImportResult;
$conn = $ctx->connection;
$legacyPortal = $ctx->legacyPortalValue();
$portal = $ctx->portalEnum;
// Verantwortliche User voraufladen (responsible_company_user)
$responsibles = DB::connection($conn)
->table('responsible_company_user')
->get()
->groupBy('company_id')
->map(fn ($rows) => $rows->pluck('user_id')->all());
// Einfache company_user Mitglieder
$members = DB::connection($conn)
->table('company_user')
->get()
->groupBy('company_id')
->map(fn ($rows) => $rows->pluck('user_id')->all());
DB::connection($conn)
->table('company')
->orderBy('id')
->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $legacyPortal, $portal, $responsibles, $members): void {
foreach ($rows as $row) {
try {
$this->importRow($row, $ctx, $result, $legacyPortal, $portal, $responsibles, $members);
} catch (\Throwable $e) {
$result->addError("Company legacy_id={$row->id}: {$e->getMessage()}");
}
}
});
return $result;
}
private function importRow(
object $row,
ImportContext $ctx,
ImportResult $result,
string $legacyPortal,
Portal $portal,
Collection $responsibles,
Collection $members,
): void {
$alreadyImported = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'company')
->where('legacy_id', $row->id)
->exists();
if ($alreadyImported && ! $ctx->force) {
$result->incrementSkipped();
return;
}
if ($ctx->dryRun) {
$result->incrementImported();
return;
}
// Owner-User aus Import-Map auflösen
$ownerUserId = null;
if ($row->user_id) {
$map = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'sf_guard_user')
->where('legacy_id', $row->user_id)
->first();
$ownerUserId = $map?->target_id;
}
$type = self::CTYPE_MAP[$row->ctype] ?? CompanyType::Company;
// Eindeutigen Slug sicherstellen
$slug = $this->uniqueSlug($row->slug ?: Str::slug($row->name) ?: 'firma', $portal->value);
$company = Company::withoutTimestamps(function () use ($legacyPortal, $row, $portal, $ownerUserId, $type, $slug): Company {
return Company::withoutGlobalScopes()->updateOrCreate(
['legacy_portal' => $legacyPortal, 'legacy_id' => $row->id],
[
'portal' => $portal->value,
'owner_user_id' => $ownerUserId,
'type' => $type->value,
'name' => $row->name ?: 'Unbekannte Firma',
'slug' => $slug,
'address' => $row->address ?: null,
'phone' => $this->cleanText($row->phone, 255),
'fax' => $this->cleanText($row->fax, 255),
'email' => $this->cleanText($row->email, 190),
'website' => $this->cleanText($row->website, 190),
'logo_path' => $row->logo ?: null,
'is_active' => (bool) $row->is_active,
'disable_footer_code' => (bool) ($row->disable_footer_code ?? false),
'created_at' => $row->created_at ?? now(),
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
]
);
});
// Pivot-Zuordnungen (member + responsible)
$pivotPayload = [];
foreach ($members->get($row->id, []) as $legacyUserId) {
$userMap = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'sf_guard_user')
->where('legacy_id', $legacyUserId)
->first();
if ($userMap) {
$role = in_array($legacyUserId, $responsibles->get($row->id, []))
? 'responsible'
: 'member';
$pivotPayload[$userMap->target_id] = ['role' => $role];
}
}
if ($pivotPayload !== []) {
$company->users()->syncWithoutDetaching($pivotPayload);
}
LegacyImportMap::query()->updateOrCreate(
[
'legacy_portal' => $legacyPortal,
'legacy_table' => 'company',
'legacy_id' => $row->id,
],
[
'target_table' => 'companies',
'target_id' => $company->id,
'imported_at' => now(),
]
);
if ($alreadyImported) {
$result->incrementUpdated();
} else {
$result->incrementImported();
}
}
private function uniqueSlug(string $base, string $portal): string
{
$slug = $base;
$i = 2;
while (Company::withoutGlobalScopes()
->where('portal', $portal)
->where('slug', $slug)
->exists()
) {
$slug = $base.'-'.$i++;
}
return $slug;
}
/** Bereinigt HTML-Entities und kürzt auf maximale Feldlänge. */
private function cleanText(?string $value, int $maxLength): ?string
{
if (blank($value)) {
return null;
}
// HTML-Entities dekodieren (z.B. &#8201; → Thin Space → entfernen)
$clean = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Steuerzeichen und unsichtbare Unicode-Zeichen entfernen
$clean = preg_replace('/[\x00-\x1F\x7F\xC2\xA0]/u', ' ', $clean) ?? $clean;
$clean = trim((string) $clean);
if (blank($clean)) {
return null;
}
return mb_substr($clean, 0, $maxLength);
}
}

View file

@ -0,0 +1,143 @@
<?php
namespace App\Services\Import;
use App\Enums\Portal;
use App\Models\Contact;
use App\Models\LegacyImportMap;
use Illuminate\Support\Facades\DB;
class ContactImporter
{
private const CHUNK_SIZE = 500;
public function run(ImportContext $ctx): ImportResult
{
$result = new ImportResult;
$conn = $ctx->connection;
$legacyPortal = $ctx->legacyPortalValue();
$portal = $ctx->portalEnum;
DB::connection($conn)
->table('contact')
->orderBy('id')
->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $legacyPortal, $portal): void {
foreach ($rows as $row) {
try {
$this->importRow($row, $ctx, $result, $legacyPortal, $portal);
} catch (\Throwable $e) {
$result->addError("Contact legacy_id={$row->id}: {$e->getMessage()}");
}
}
});
return $result;
}
private function importRow(
object $row,
ImportContext $ctx,
ImportResult $result,
string $legacyPortal,
Portal $portal,
): void {
$alreadyImported = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'contact')
->where('legacy_id', $row->id)
->exists();
if ($alreadyImported && ! $ctx->force) {
$result->incrementSkipped();
return;
}
if ($ctx->dryRun) {
$result->incrementImported();
return;
}
// Firma aus Import-Map auflösen
$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;
}
if (! $companyId) {
// Kontakt ohne zugeordnete Firma überspringen (FK-Constraint)
$result->incrementSkipped();
return;
}
$salutationKey = $this->mapSalutation($row->salutation_id ?? 0);
$contact = Contact::withoutTimestamps(function () use ($legacyPortal, $row, $companyId, $portal, $salutationKey): Contact {
return Contact::withoutGlobalScopes()->updateOrCreate(
['legacy_portal' => $legacyPortal, 'legacy_id' => $row->id],
[
'company_id' => $companyId,
'portal' => $portal->value,
'salutation_key' => $salutationKey,
'title' => $this->cleanText($row->title, 80),
'first_name' => $this->cleanText($row->first_name, 80),
'last_name' => $this->cleanText($row->last_name, 80),
'responsibility' => $this->cleanText($row->responsibility, 255),
'phone' => $this->cleanText($row->phone, 255),
'fax' => $this->cleanText($row->fax, 255),
'email' => $this->cleanText($row->email, 190),
'created_at' => $row->created_at ?? now(),
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
]
);
});
LegacyImportMap::query()->updateOrCreate(
[
'legacy_portal' => $legacyPortal,
'legacy_table' => 'contact',
'legacy_id' => $row->id,
],
[
'target_table' => 'contacts',
'target_id' => $contact->id,
'imported_at' => now(),
]
);
if ($alreadyImported) {
$result->incrementUpdated();
} else {
$result->incrementImported();
}
}
private function mapSalutation(int $salutationId): ?string
{
return match ($salutationId) {
1 => 'mr',
2 => 'mrs',
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);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Services\Import;
use App\Enums\Portal;
/**
* Hält den Kontext für einen Import-Lauf.
*/
final class ImportContext
{
public readonly ?Portal $portalEnum;
public readonly ?string $connection;
public function __construct(
public readonly string $portal, // 'presseecho' | 'businessportal24' | 'all'
public readonly bool $dryRun = false,
public readonly bool $force = false,
) {
if ($portal === 'all') {
$this->portalEnum = null;
$this->connection = null;
} else {
$this->portalEnum = Portal::from($portal);
$this->connection = match ($portal) {
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
default => throw new \InvalidArgumentException("Unbekanntes Portal: {$portal}"),
};
}
}
public function legacyPortalValue(): string
{
return $this->portal;
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Services\Import;
final class ImportResult
{
private int $imported = 0;
private int $skipped = 0;
private int $updated = 0;
private int $failed = 0;
/** @var string[] */
private array $errors = [];
public function incrementImported(): void
{
$this->imported++;
}
public function incrementSkipped(): void
{
$this->skipped++;
}
public function incrementUpdated(): void
{
$this->updated++;
}
public function addError(string $message): void
{
$this->failed++;
$this->errors[] = $message;
}
public function imported(): int
{
return $this->imported;
}
public function skipped(): int
{
return $this->skipped;
}
public function updated(): int
{
return $this->updated;
}
public function failed(): int
{
return $this->failed;
}
/** @return string[] */
public function errors(): array
{
return $this->errors;
}
public function summary(): string
{
return "Importiert: {$this->imported} | Übersprungen: {$this->skipped} | Aktualisiert: {$this->updated} | Fehler: {$this->failed}";
}
}

View file

@ -0,0 +1,261 @@
<?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')
->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;
}
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): bestehenden Slug behalten, um Kollisionen zu vermeiden.
$existingPr = $alreadyImported
? PressRelease::withoutGlobalScopes()
->where('legacy_portal', $legacyPortal)
->where('legacy_id', $row->id)
->first(['id', 'slug'])
: null;
$slug = $existingPr?->slug ?? $this->uniqueSlug(
$row->slug ?: Str::slug($row->title) ?: 'pressemitteilung',
$portal->value,
$language,
$existingPr?->id,
);
$publishedAt = ($status === PressReleaseStatus::Published)
? ($row->updated_at ?? $row->created_at)
: null;
$pr = PressRelease::withoutTimestamps(function () use (
$legacyPortal, $row, $portal, $userId, $companyId, $categoryId,
$language, $slug, $status, $publishedAt,
): PressRelease {
return PressRelease::withoutGlobalScopes()->updateOrCreate(
['legacy_portal' => $legacyPortal, 'legacy_id' => $row->id],
[
'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' => $row->created_at ?? now(),
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
]
);
});
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 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;
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace App\Services\Import;
use Illuminate\Support\Facades\DB;
/**
* Post-Import-Schritt: verknüpft User direkt mit Kontakten.
*
* Logik: User company_user company contacts
* Ein User der mit einer Firma verknüpft ist, bekommt automatisch alle
* Kontakte dieser Firma direkt in contact_user eingetragen.
*
* Läuft auf der NEUEN Datenbank (kein Legacy-DB-Zugriff nötig).
* Idempotent: insertOrIgnore() verhindert Duplikate bei Mehrfachläufen.
* Manuelle Admin-Zuordnungen (über Admin-UI erstellt) bleiben erhalten.
*/
class UserAssociationLinker
{
public function run(ImportContext $ctx): ImportResult
{
$result = new ImportResult;
if ($ctx->dryRun) {
$count = $this->countPotentialLinks($ctx);
for ($i = 0; $i < $count; $i++) {
$result->incrementImported();
}
return $result;
}
$inserted = $this->linkContactsViaCompany($ctx);
for ($i = 0; $i < $inserted; $i++) {
$result->incrementImported();
}
return $result;
}
private function linkContactsViaCompany(ImportContext $ctx): int
{
$portalFilter = $ctx->portal !== 'all' ? $ctx->portalEnum->value : null;
// Alle (user_id, contact_id) Paare aus der Firmenzugehörigkeit ableiten
$pairs = DB::table('company_user')
->join('contacts', 'contacts.company_id', '=', 'company_user.company_id')
->when($portalFilter, fn ($q) => $q->where('contacts.portal', $portalFilter))
->whereNull('contacts.deleted_at')
->select(
'company_user.user_id',
'contacts.id as contact_id',
DB::raw('NOW() as created_at'),
DB::raw('NOW() as updated_at'),
)
->distinct()
->get();
if ($pairs->isEmpty()) {
return 0;
}
// Chunked insert um Memory zu schonen
$inserted = 0;
foreach ($pairs->chunk(500) as $chunk) {
$inserted += DB::table('contact_user')->insertOrIgnore(
$chunk->map(fn ($row) => [
'contact_id' => $row->contact_id,
'user_id' => $row->user_id,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
])->all()
);
}
return $inserted;
}
private function countPotentialLinks(ImportContext $ctx): int
{
$portalFilter = $ctx->portal !== 'all' ? $ctx->portalEnum->value : null;
return DB::table('company_user')
->join('contacts', 'contacts.company_id', '=', 'company_user.company_id')
->when($portalFilter, fn ($q) => $q->where('contacts.portal', $portalFilter))
->whereNull('contacts.deleted_at')
->distinct()
->count();
}
}

View file

@ -0,0 +1,262 @@
<?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 */
private const GROUP_ROLE_MAP = [
1 => 'admin',
2 => 'editor',
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',
])
->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 {
return User::query()->updateOrCreate(
['email' => $email],
[
'name' => $name,
'portal' => $portal->value,
'registration_type' => $regType->value,
'language' => $language,
'is_active' => (bool) $row->is_active,
'is_super_admin' => (bool) $row->is_super_admin,
'last_login_at' => $row->last_login ?: null,
'last_login_ip' => $row->ip_address ?: null,
'legacy_portal' => $legacyPortal,
'legacy_id' => $row->legacy_id,
'created_at' => $row->created_at ?? now(),
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
// Kein Passwort User erhält Go-Live-Reset-Mail (D-09)
]
);
});
// 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;
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Services\Newsletter;
use App\Contracts\NewsletterSyncClient;
use App\Models\NewsletterSubscription;
class NewsletterSyncService
{
public function __construct(
private readonly NewsletterSyncClient $client
) {}
public function syncSubscription(NewsletterSubscription $subscription): void
{
if ($subscription->is_confirmed && $subscription->unsubscribed_at === null) {
$this->client->subscribe($subscription);
return;
}
$this->client->unsubscribe($subscription);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Services\Newsletter;
use App\Contracts\NewsletterSyncClient;
use App\Models\NewsletterSubscription;
class NullNewsletterSyncClient implements NewsletterSyncClient
{
public function subscribe(NewsletterSubscription $subscription): void
{
// Placeholder: externe Newsletter-Synchronisierung wird spaeter implementiert.
}
public function unsubscribe(NewsletterSubscription $subscription): void
{
// Placeholder: externe Newsletter-Synchronisierung wird spaeter implementiert.
}
}

View file

@ -0,0 +1,106 @@
<?php
namespace App\Services\PressRelease;
use App\Models\PressRelease;
/**
* Wortbasierte Blacklist-Prüfung für Pressemitteilungen.
*
* Die Liste kommt aktuell aus config/blacklist.php; ein Wechsel auf
* eine Datenbank-Tabelle (Admin-UI) ist später ohne API-Bruch möglich.
*/
class BlacklistService
{
/**
* @var list<string>|null
*/
private ?array $words = null;
/**
* @param array{words?: list<string>}|null $config
*/
public function __construct(?array $config = null)
{
if ($config !== null) {
$this->words = $this->normalizeList($config['words'] ?? []);
}
}
/**
* Findet das erste Blacklist-Wort, das in Titel oder Text vorkommt.
*/
public function findInPressRelease(PressRelease $pressRelease): ?string
{
$haystack = $pressRelease->title.' '.$pressRelease->text;
return $this->find($haystack);
}
/**
* Findet das erste Blacklist-Wort in einem beliebigen String.
* Vergleich ist case-insensitiv und ganzwörtlich.
*/
public function find(string $text): ?string
{
$words = $this->words();
if ($words === []) {
return null;
}
$needle = $this->normalizeText($text);
foreach ($words as $word) {
$padded = ' '.trim($word).' ';
if (trim($padded) === '') {
continue;
}
if (str_contains($needle, $padded)) {
return trim($padded);
}
}
return null;
}
public function matches(string $text): bool
{
return $this->find($text) !== null;
}
/**
* @return list<string>
*/
private function words(): array
{
if ($this->words === null) {
$this->words = $this->normalizeList((array) config('blacklist.words', []));
}
return $this->words;
}
/**
* @param array<int, mixed> $words
* @return list<string>
*/
private function normalizeList(array $words): array
{
return array_values(array_filter(array_map(
fn ($word): string => $this->normalizeText((string) $word),
$words,
), fn (string $word): bool => $word !== ''));
}
private function normalizeText(string $text): string
{
$text = mb_strtolower($text, 'UTF-8');
$text = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $text) ?? '';
$text = preg_replace('/\s+/u', ' ', $text) ?? '';
return ' '.trim($text).' ';
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
class BlacklistViolationException extends RuntimeException
{
public function __construct(string $message, public readonly string $word)
{
parent::__construct($message);
}
}

View file

@ -0,0 +1,196 @@
<?php
namespace App\Services\PressRelease;
use App\Enums\PressReleaseStatus;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\AdminPreset;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
/**
* Kapselt Statusübergänge für Pressemitteilungen inkl. Benachrichtigungs-Mails.
*
* Jede Methode verändert exklusiv den Status und versendet optional eine Mail
* an den Autor (User). Die Caller-Schicht (Volt, Command, API) muss nur noch
* diese Methoden aufrufen keine Mail-Logik außerhalb dieser Klasse.
*/
class PressReleaseService
{
public function __construct(private readonly BlacklistService $blacklist) {}
public function submitForReview(PressRelease $pressRelease): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]);
$previous = $pressRelease->status;
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
$reason = sprintf('Automatische Ablehnung: unzulässiges Wort "%s" gefunden.', $word);
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'blacklist');
$this->notifyAuthor($pressRelease, 'rejected', $reason);
throw new BlacklistViolationException($reason, $word);
}
$pressRelease->update(['status' => PressReleaseStatus::Review->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
}
public function publish(PressRelease $pressRelease): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
$previous = $pressRelease->status;
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
$reason = sprintf('Automatische Ablehnung: unzulässiges Wort "%s" gefunden.', $word);
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'blacklist');
$this->notifyAuthor($pressRelease, 'rejected', $reason);
throw new BlacklistViolationException($reason, $word);
}
$pressRelease->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => $pressRelease->published_at ?? now(),
]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, 'admin');
$this->notifyAuthor($pressRelease, 'published');
}
public function reject(PressRelease $pressRelease, ?string $reason = null): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
$previous = $pressRelease->status;
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'admin');
$this->notifyAuthor($pressRelease, 'rejected', $reason);
}
public function backToDraft(PressRelease $pressRelease): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review, PressReleaseStatus::Rejected]);
$previous = $pressRelease->status;
$pressRelease->update(['status' => PressReleaseStatus::Draft->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Draft, null, 'admin');
}
public function archive(PressRelease $pressRelease): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Published]);
$previous = $pressRelease->status;
$pressRelease->update(['status' => PressReleaseStatus::Archived->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, null, 'admin');
}
public function changeStatusFromAdmin(PressRelease $pressRelease, PressReleaseStatus $status, ?string $reason = null): void
{
$previous = $pressRelease->status;
$pressRelease->update([
'status' => $status->value,
'published_at' => $status === PressReleaseStatus::Published
? ($pressRelease->published_at ?? now())
: $pressRelease->published_at,
]);
if ($previous !== $status) {
$this->logStatusChange($pressRelease, $previous, $status, $reason, 'admin');
}
}
public function deleteFromAdmin(PressRelease $pressRelease): void
{
if ($pressRelease->status === PressReleaseStatus::Published) {
$pressRelease->update([
'status' => PressReleaseStatus::Archived->value,
'text' => AdminPreset::activeValue(
AdminPreset::PRESS_RELEASE_DELETED_PUBLISHED_TEXT,
"Diese Pressemitteilung wurde entfernt.\n\nDer Inhalt ist nicht mehr verfuegbar."
),
'keywords' => null,
'backlink_url' => null,
'no_export' => true,
]);
return;
}
$pressRelease->delete();
}
/**
* @param PressReleaseStatus[] $allowed
*/
private function assertStatus(PressRelease $pressRelease, array $allowed): void
{
if (! in_array($pressRelease->status, $allowed, true)) {
$allowedValues = implode(', ', array_map(fn ($s) => $s->value, $allowed));
$currentStatus = $pressRelease->status instanceof PressReleaseStatus
? $pressRelease->status->value
: (string) $pressRelease->status;
throw new \LogicException(
"Statusübergang nicht erlaubt. Aktueller Status: {$currentStatus}, erwartet: {$allowedValues}"
);
}
}
private function logStatusChange(
PressRelease $pressRelease,
?PressReleaseStatus $from,
PressReleaseStatus $to,
?string $reason,
string $source,
): void {
PressReleaseStatusLog::query()->create([
'press_release_id' => $pressRelease->id,
'changed_by_user_id' => Auth::id(),
'from_status' => $from?->value,
'to_status' => $to->value,
'reason' => $reason,
'source' => $source,
'created_at' => now(),
]);
Cache::forget(AdminPerformanceCache::PressReleaseStats);
Cache::forget(AdminPerformanceCache::PressReleaseReviewCount);
}
private function notifyAuthor(PressRelease $pressRelease, string $event, ?string $reason = null): void
{
$user = $pressRelease->user;
if (! $user || ! $user->email) {
return;
}
$mailable = match ($event) {
'published' => new PressReleasePublished($user, $pressRelease),
'rejected' => new PressReleaseRejected($user, $pressRelease, $reason),
default => null,
};
if ($mailable) {
Mail::to($user->email)->queue($mailable);
}
}
}