12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
109
app/Services/Admin/AdminPerformanceCache.php
Normal file
109
app/Services/Admin/AdminPerformanceCache.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
78
app/Services/Admin/AdminRequestPerformanceMetrics.php
Normal file
78
app/Services/Admin/AdminRequestPerformanceMetrics.php
Normal 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));
|
||||
}
|
||||
}
|
||||
323
app/Services/Admin/AdminSlowRequestReporter.php
Normal file
323
app/Services/Admin/AdminSlowRequestReporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
57
app/Services/Api/ApiAccessEligibilityService.php
Normal file
57
app/Services/Api/ApiAccessEligibilityService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
106
app/Services/Api/ApiUsageReporter.php
Normal file
106
app/Services/Api/ApiUsageReporter.php
Normal 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();
|
||||
}
|
||||
}
|
||||
184
app/Services/Api/LegacyApiAccessLogAnalyzer.php
Normal file
184
app/Services/Api/LegacyApiAccessLogAnalyzer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/Services/Api/LegacyApiCustomerReporter.php
Normal file
143
app/Services/Api/LegacyApiCustomerReporter.php
Normal 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';
|
||||
}
|
||||
}
|
||||
75
app/Services/Auth/MagicLinkGenerator.php
Normal file
75
app/Services/Auth/MagicLinkGenerator.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Services/Auth/UserRolePermissionSyncService.php
Normal file
19
app/Services/Auth/UserRolePermissionSyncService.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
193
app/Services/Billing/LegacyInvoicePdfRenderer.php
Normal file
193
app/Services/Billing/LegacyInvoicePdfRenderer.php
Normal 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));
|
||||
}
|
||||
}
|
||||
213
app/Services/CategoryService.php
Normal file
213
app/Services/CategoryService.php
Normal 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());
|
||||
}
|
||||
}
|
||||
36
app/Services/CurrentPortalContext.php
Normal file
36
app/Services/CurrentPortalContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
125
app/Services/Customer/CustomerCompanyContext.php
Normal file
125
app/Services/Customer/CustomerCompanyContext.php
Normal 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();
|
||||
}
|
||||
}
|
||||
416
app/Services/Image/ImageService.php
Normal file
416
app/Services/Image/ImageService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
124
app/Services/Import/CategoryImporter.php
Normal file
124
app/Services/Import/CategoryImporter.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
203
app/Services/Import/CompanyImporter.php
Normal file
203
app/Services/Import/CompanyImporter.php
Normal 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.   → 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);
|
||||
}
|
||||
}
|
||||
143
app/Services/Import/ContactImporter.php
Normal file
143
app/Services/Import/ContactImporter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
38
app/Services/Import/ImportContext.php
Normal file
38
app/Services/Import/ImportContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
69
app/Services/Import/ImportResult.php
Normal file
69
app/Services/Import/ImportResult.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
261
app/Services/Import/PressReleaseImporter.php
Normal file
261
app/Services/Import/PressReleaseImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
91
app/Services/Import/UserAssociationLinker.php
Normal file
91
app/Services/Import/UserAssociationLinker.php
Normal 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();
|
||||
}
|
||||
}
|
||||
262
app/Services/Import/UserImporter.php
Normal file
262
app/Services/Import/UserImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
24
app/Services/Newsletter/NewsletterSyncService.php
Normal file
24
app/Services/Newsletter/NewsletterSyncService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
app/Services/Newsletter/NullNewsletterSyncClient.php
Normal file
19
app/Services/Newsletter/NullNewsletterSyncClient.php
Normal 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.
|
||||
}
|
||||
}
|
||||
106
app/Services/PressRelease/BlacklistService.php
Normal file
106
app/Services/PressRelease/BlacklistService.php
Normal 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).' ';
|
||||
}
|
||||
}
|
||||
13
app/Services/PressRelease/BlacklistViolationException.php
Normal file
13
app/Services/PressRelease/BlacklistViolationException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
196
app/Services/PressRelease/PressReleaseService.php
Normal file
196
app/Services/PressRelease/PressReleaseService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue