12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
93
app/Actions/Admin/UserImpersonation.php
Normal file
93
app/Actions/Admin/UserImpersonation.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class UserImpersonation
|
||||
{
|
||||
public const SessionKey = 'impersonate_from';
|
||||
|
||||
public function canInitiate(User $user): bool
|
||||
{
|
||||
return $user->is_super_admin || $user->can('users:manage');
|
||||
}
|
||||
|
||||
public function canStart(User $admin, User $target): bool
|
||||
{
|
||||
return $this->canInitiate($admin)
|
||||
&& ! $admin->is($target)
|
||||
&& ! $this->isActive()
|
||||
&& $this->canBeImpersonated($target);
|
||||
}
|
||||
|
||||
public function canBeImpersonated(User $target): bool
|
||||
{
|
||||
return $target->canAccessCustomer();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function start(User $admin, User $target): void
|
||||
{
|
||||
if (! $this->canInitiate($admin)) {
|
||||
throw new AuthorizationException(__('Du darfst keine Benutzer-Impersonation starten.'));
|
||||
}
|
||||
|
||||
if ($admin->is($target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isActive()) {
|
||||
throw new AuthorizationException(__('Verschachtelte Benutzer-Impersonation ist nicht erlaubt.'));
|
||||
}
|
||||
|
||||
if (! $this->canBeImpersonated($target)) {
|
||||
throw new AuthorizationException(__('Dieser Benutzer kann nicht im Panel angemeldet werden.'));
|
||||
}
|
||||
|
||||
session([self::SessionKey => $admin->getKey()]);
|
||||
|
||||
Auth::login($target);
|
||||
}
|
||||
|
||||
public function stop(): ?User
|
||||
{
|
||||
$adminUserId = session(self::SessionKey);
|
||||
|
||||
session()->forget(self::SessionKey);
|
||||
|
||||
if (! is_numeric($adminUserId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$admin = User::query()->find((int) $adminUserId);
|
||||
|
||||
if (! $admin?->canAccessAdmin()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Auth::login($admin);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return session()->has(self::SessionKey);
|
||||
}
|
||||
|
||||
public function impersonator(): ?User
|
||||
{
|
||||
$adminUserId = session(self::SessionKey);
|
||||
|
||||
if (! is_numeric($adminUserId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return User::query()->find((int) $adminUserId);
|
||||
}
|
||||
}
|
||||
87
app/Console/Commands/AnalyzeLegacyApiAccessLogs.php
Normal file
87
app/Console/Commands/AnalyzeLegacyApiAccessLogs.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Api\LegacyApiAccessLogAnalyzer;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AnalyzeLegacyApiAccessLogs extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'api:analyze-legacy-access-logs
|
||||
{paths* : Access-log files or glob patterns}
|
||||
{--top=20 : Number of top entries per section}
|
||||
{--no-report : Keinen JSON-Report schreiben}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Analysiert Legacy-API-Zugriffe aus Webserver-Access-Logs.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(LegacyApiAccessLogAnalyzer $analyzer): int
|
||||
{
|
||||
$paths = array_map('strval', (array) $this->argument('paths'));
|
||||
$top = max(1, (int) $this->option('top'));
|
||||
$report = $analyzer->analyze($paths, $top);
|
||||
|
||||
$this->info('Legacy-API-Access-Log-Auswertung');
|
||||
$this->newLine();
|
||||
$this->line("Dateien: {$report['summary']['files']}");
|
||||
$this->line("Log-Zeilen: {$report['summary']['total_lines']}");
|
||||
$this->line("Legacy-API-Requests: {$report['summary']['matched_requests']}");
|
||||
$this->line("Requests mit api_key: {$report['summary']['legacy_key_requests']}");
|
||||
$this->line("Eindeutige Client-IPs: {$report['summary']['unique_client_ips']}");
|
||||
$this->line("Eindeutige API-Key-Fingerprints: {$report['summary']['unique_api_key_fingerprints']}");
|
||||
|
||||
if ($report['missing_paths'] !== []) {
|
||||
$this->newLine();
|
||||
$this->warn('Nicht lesbare Pfade:');
|
||||
foreach ($report['missing_paths'] as $path) {
|
||||
$this->line(" - {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->renderTopTable('Top Endpoints', $report['endpoints']);
|
||||
$this->renderTopTable('Top Client-IPs', $report['client_ips']);
|
||||
$this->renderTopTable('Statuscodes', $report['status_codes']);
|
||||
|
||||
if (! (bool) $this->option('no-report')) {
|
||||
$path = 'migration/legacy-api-access-'.now()->format('Ymd-His').'.json';
|
||||
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$this->newLine();
|
||||
$this->line("Report geschrieben: storage/app/private/{$path}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $rows
|
||||
*/
|
||||
private function renderTopTable(string $title, array $rows): void
|
||||
{
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line($title);
|
||||
$this->table(
|
||||
['Wert', 'Requests'],
|
||||
collect($rows)
|
||||
->map(fn (int $count, string $value): array => [$value, $count])
|
||||
->values()
|
||||
->all()
|
||||
);
|
||||
}
|
||||
}
|
||||
314
app/Console/Commands/ArchiveLegacyInvoices.php
Normal file
314
app/Console/Commands/ArchiveLegacyInvoices.php
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\LegacyImportMap;
|
||||
use App\Models\LegacyInvoice;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Archiviert alle Rechnungen aus den Legacy-Portalen in legacy_invoices (P6.5).
|
||||
*
|
||||
* Die Tabelle ist read-only und dient als Audit-Archiv.
|
||||
* Rechnungen werden nicht in den neuen Rechnungskreis (invoices) übernommen.
|
||||
*
|
||||
* Verwendung:
|
||||
* php artisan legacy:archive-invoices --dry-run
|
||||
* php artisan legacy:archive-invoices --portal=presseecho
|
||||
* php artisan legacy:archive-invoices
|
||||
*/
|
||||
class ArchiveLegacyInvoices extends Command
|
||||
{
|
||||
protected $signature = 'legacy:archive-invoices
|
||||
{--portal=all : Portal (presseecho|businessportal24|all)}
|
||||
{--dry-run : Nur zählen, nichts schreiben}
|
||||
{--force : Bereits archivierte Rechnungen aktualisieren}
|
||||
{--no-report : Keinen JSON-Report schreiben}';
|
||||
|
||||
protected $description = 'Archiviert Legacy-Rechnungen in legacy_invoices (read-only Archiv).';
|
||||
|
||||
private const PORTAL_CONFIG = [
|
||||
'presseecho' => [
|
||||
'connection' => 'mysql_presseecho',
|
||||
'portal_enum' => Portal::Presseecho,
|
||||
],
|
||||
'businessportal24' => [
|
||||
'connection' => 'mysql_businessportal',
|
||||
'portal_enum' => Portal::Businessportal24,
|
||||
],
|
||||
];
|
||||
|
||||
private const CHUNK_SIZE = 200;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$portalOpt = $this->option('portal');
|
||||
$isDryRun = (bool) $this->option('dry-run');
|
||||
$isForce = (bool) $this->option('force');
|
||||
|
||||
$portals = $portalOpt === 'all'
|
||||
? array_keys(self::PORTAL_CONFIG)
|
||||
: [$portalOpt];
|
||||
|
||||
if (array_diff($portals, array_keys(self::PORTAL_CONFIG)) !== []) {
|
||||
$this->error("Unbekanntes Portal: {$portalOpt}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||
}
|
||||
|
||||
$totalImported = 0;
|
||||
$totalSkipped = 0;
|
||||
$totalErrors = 0;
|
||||
$report = [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'portal' => $portalOpt,
|
||||
'dry_run' => $isDryRun,
|
||||
'force' => $isForce,
|
||||
'portals' => [],
|
||||
];
|
||||
$start = microtime(true);
|
||||
|
||||
foreach ($portals as $portal) {
|
||||
$config = self::PORTAL_CONFIG[$portal];
|
||||
$portalReport = $this->archivePortal(
|
||||
$portal,
|
||||
$config['connection'],
|
||||
$isDryRun,
|
||||
$isForce,
|
||||
);
|
||||
|
||||
$report['portals'][$portal] = $portalReport;
|
||||
$imported = $portalReport['imported'];
|
||||
$skipped = $portalReport['skipped'];
|
||||
$errors = $portalReport['errors'];
|
||||
$this->line(" [{$portal}] Archiviert: {$imported} | Übersprungen: {$skipped} | Fehler: {$errors}");
|
||||
$this->line(" [{$portal}] Quelle: {$portalReport['source_count']} | ohne User-Mapping: {$portalReport['unmapped_user_count']} | PDF-Payload: {$portalReport['pdf_payload_count']}");
|
||||
$totalImported += $imported;
|
||||
$totalSkipped += $skipped;
|
||||
$totalErrors += $errors;
|
||||
}
|
||||
|
||||
$elapsed = round(microtime(true) - $start, 1);
|
||||
$this->newLine();
|
||||
$this->info("Gesamt: {$totalImported} archiviert | {$totalSkipped} übersprungen | {$totalErrors} Fehler | {$elapsed}s");
|
||||
|
||||
if (! (bool) $this->option('no-report')) {
|
||||
$path = 'migration/legacy-invoices-'.now()->format('Ymd-His').'.json';
|
||||
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$this->line("Report geschrieben: storage/app/private/{$path}");
|
||||
}
|
||||
|
||||
return $totalErrors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function safeDateOrNull(mixed $value): ?string
|
||||
{
|
||||
if (blank($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
private function archivePortal(
|
||||
string $portal,
|
||||
string $connection,
|
||||
bool $isDryRun,
|
||||
bool $isForce,
|
||||
): array {
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
$unmappedUserCount = 0;
|
||||
$pdfPayloadCount = 0;
|
||||
$amountCents = 0;
|
||||
$statusCounts = [];
|
||||
$hasBillingAddressTable = Schema::connection($connection)->hasTable('invoice_billing_address');
|
||||
$hasUserPaymentTable = Schema::connection($connection)->hasTable('user_payment');
|
||||
$hasUserPaymentOptionTable = Schema::connection($connection)->hasTable('user_payment_option');
|
||||
$hasPaymentOptionTable = Schema::connection($connection)->hasTable('payment_option');
|
||||
$hasPaymentOptionTranslationTable = Schema::connection($connection)->hasTable('payment_option_translation');
|
||||
$sourceCount = DB::connection($connection)->table('invoice')->count();
|
||||
|
||||
DB::connection($connection)
|
||||
->table('invoice')
|
||||
->orderBy('id')
|
||||
->chunk(self::CHUNK_SIZE, function ($rows) use (
|
||||
$portal, $connection, $isDryRun, $isForce, $hasBillingAddressTable, $hasUserPaymentTable,
|
||||
$hasUserPaymentOptionTable, $hasPaymentOptionTable, $hasPaymentOptionTranslationTable,
|
||||
&$imported, &$skipped, &$errors, &$unmappedUserCount, &$pdfPayloadCount, &$amountCents, &$statusCounts,
|
||||
): void {
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$status = (string) ($row->status ?? 'unknown');
|
||||
$rowAmountCents = (int) round((float) $row->amount * 100);
|
||||
$amountCents += $rowAmountCents;
|
||||
$statusCounts[$status] = ($statusCounts[$status] ?? 0) + 1;
|
||||
|
||||
$existingInvoice = LegacyInvoice::query()
|
||||
->where('legacy_portal', $portal)
|
||||
->where('legacy_id', $row->id)
|
||||
->first();
|
||||
|
||||
if ($existingInvoice && ! $isForce) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$imported++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$userId = null;
|
||||
if ($row->user_id) {
|
||||
$userMap = LegacyImportMap::query()
|
||||
->where('legacy_portal', $portal)
|
||||
->where('legacy_table', 'sf_guard_user')
|
||||
->where('legacy_id', $row->user_id)
|
||||
->first();
|
||||
$userId = $userMap?->target_id;
|
||||
}
|
||||
|
||||
if ($userId === null) {
|
||||
$unmappedUserCount++;
|
||||
}
|
||||
|
||||
$pdfPayload = $this->pdfPayload(
|
||||
$connection,
|
||||
$row,
|
||||
$hasBillingAddressTable,
|
||||
$hasUserPaymentTable,
|
||||
$hasUserPaymentOptionTable,
|
||||
$hasPaymentOptionTable,
|
||||
$hasPaymentOptionTranslationTable,
|
||||
);
|
||||
$pdfPayloadCount++;
|
||||
|
||||
$taxCents = 0;
|
||||
$totalCents = $rowAmountCents;
|
||||
|
||||
$paidAt = $this->safeDateOrNull($row->pay_date);
|
||||
|
||||
LegacyInvoice::query()->updateOrCreate(
|
||||
[
|
||||
'legacy_portal' => $portal,
|
||||
'legacy_id' => $row->id,
|
||||
],
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'legacy_user_id' => $row->user_id,
|
||||
'number' => (string) $row->number,
|
||||
'amount_cents' => $rowAmountCents,
|
||||
'tax_cents' => $taxCents,
|
||||
'total_cents' => $totalCents,
|
||||
'status' => $status,
|
||||
'invoice_date' => $this->safeDateOrNull($row->invoice_date),
|
||||
'due_date' => $this->safeDateOrNull($row->due_date),
|
||||
'paid_at' => $paidAt,
|
||||
'payment_method' => $row->payment_method ?: null,
|
||||
'pdf_path' => $existingInvoice?->pdf_path,
|
||||
'raw_snapshot' => (array) $row,
|
||||
'pdf_payload' => $pdfPayload,
|
||||
'imported_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
$imported++;
|
||||
} catch (\Throwable $e) {
|
||||
$errors++;
|
||||
$this->warn(" ! Invoice {$row->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ksort($statusCounts);
|
||||
|
||||
return [
|
||||
'source_count' => $sourceCount,
|
||||
'imported' => $imported,
|
||||
'skipped' => $skipped,
|
||||
'errors' => $errors,
|
||||
'unmapped_user_count' => $unmappedUserCount,
|
||||
'pdf_payload_count' => $pdfPayloadCount,
|
||||
'amount_cents' => $amountCents,
|
||||
'status_counts' => $statusCounts,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function pdfPayload(
|
||||
string $connection,
|
||||
object $invoice,
|
||||
bool $hasBillingAddressTable,
|
||||
bool $hasUserPaymentTable,
|
||||
bool $hasUserPaymentOptionTable,
|
||||
bool $hasPaymentOptionTable,
|
||||
bool $hasPaymentOptionTranslationTable,
|
||||
): array {
|
||||
$userPayment = $hasUserPaymentTable && $invoice->user_payment_id
|
||||
? $this->legacyRow($connection, 'user_payment', (int) $invoice->user_payment_id)
|
||||
: null;
|
||||
|
||||
$userPaymentOption = $userPayment && $hasUserPaymentOptionTable
|
||||
? $this->legacyRow($connection, 'user_payment_option', (int) $userPayment['user_payment_option_id'])
|
||||
: null;
|
||||
|
||||
$paymentOption = $userPaymentOption && $hasPaymentOptionTable
|
||||
? $this->legacyRow($connection, 'payment_option', (int) $userPaymentOption['payment_option_id'])
|
||||
: null;
|
||||
|
||||
return [
|
||||
'invoice' => (array) $invoice,
|
||||
'billing_address' => $hasBillingAddressTable && $invoice->billing_address_id
|
||||
? $this->legacyRow($connection, 'invoice_billing_address', (int) $invoice->billing_address_id)
|
||||
: null,
|
||||
'user_payment' => $userPayment,
|
||||
'user_payment_option' => $userPaymentOption,
|
||||
'payment_option' => $paymentOption,
|
||||
'payment_option_translation' => $paymentOption && $hasPaymentOptionTranslationTable
|
||||
? $this->paymentOptionTranslation($connection, (int) $paymentOption['id'])
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function paymentOptionTranslation(string $connection, int $paymentOptionId): ?array
|
||||
{
|
||||
$row = DB::connection($connection)
|
||||
->table('payment_option_translation')
|
||||
->where('id', $paymentOptionId)
|
||||
->whereIn('lang', ['de', 'de_DE', 'de_AT', 'de_CH', 'en'])
|
||||
->orderByRaw("CASE lang WHEN 'de' THEN 0 WHEN 'de_DE' THEN 1 WHEN 'de_AT' THEN 2 WHEN 'de_CH' THEN 3 ELSE 4 END")
|
||||
->first();
|
||||
|
||||
return $row ? (array) $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function legacyRow(string $connection, string $table, int $id): ?array
|
||||
{
|
||||
$row = DB::connection($connection)
|
||||
->table($table)
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
return $row ? (array) $row : null;
|
||||
}
|
||||
}
|
||||
142
app/Console/Commands/FixLegacyTimestamps.php
Normal file
142
app/Console/Commands/FixLegacyTimestamps.php
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Korrigiert created_at / updated_at auf bereits importierten Datensätzen.
|
||||
*
|
||||
* Verwendet Cross-DB-JOINs (gleicher MySQL-Server), um die Legacy-Timestamps
|
||||
* in einer einzigen SQL-Operation auf die neuen Tabellen zu übertragen.
|
||||
* Viel schneller als ein erneuter --force Import.
|
||||
*
|
||||
* Verwendung:
|
||||
* php artisan legacy:fix-timestamps --dry-run # nur zählen
|
||||
* php artisan legacy:fix-timestamps # alle Entitäten, beide Portale
|
||||
* php artisan legacy:fix-timestamps --entity=companies --portal=presseecho
|
||||
*/
|
||||
class FixLegacyTimestamps extends Command
|
||||
{
|
||||
protected $signature = 'legacy:fix-timestamps
|
||||
{--entity=all : Entität (users|companies|contacts|press-releases|all)}
|
||||
{--portal=all : Portal (presseecho|businessportal24|all)}
|
||||
{--dry-run : Nur zählen, nichts schreiben}';
|
||||
|
||||
protected $description = 'Korrigiert created_at/updated_at auf importierten Datensätzen aus den Legacy-DBs.';
|
||||
|
||||
private const PORTAL_DB = [
|
||||
'presseecho' => 'presseecho',
|
||||
'businessportal24' => 'businessportal',
|
||||
];
|
||||
|
||||
/** Mapping: neue Tabelle → (legacy_portal, legacy_db, legacy_table, legacy_created_col) */
|
||||
private const ENTITY_CONFIG = [
|
||||
'users' => [
|
||||
'new_table' => 'users',
|
||||
'legacy_table_name' => 'sf_guard_user',
|
||||
'legacy_created' => 'created_at',
|
||||
'legacy_updated' => 'updated_at',
|
||||
],
|
||||
'companies' => [
|
||||
'new_table' => 'companies',
|
||||
'legacy_table_name' => 'company',
|
||||
'legacy_created' => 'created_at',
|
||||
'legacy_updated' => 'updated_at',
|
||||
],
|
||||
'contacts' => [
|
||||
'new_table' => 'contacts',
|
||||
'legacy_table_name' => 'contact',
|
||||
'legacy_created' => 'created_at',
|
||||
'legacy_updated' => 'updated_at',
|
||||
],
|
||||
'press-releases' => [
|
||||
'new_table' => 'press_releases',
|
||||
'legacy_table_name' => 'press_release',
|
||||
'legacy_created' => 'created_at',
|
||||
'legacy_updated' => 'updated_at',
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$entityOpt = $this->option('entity');
|
||||
$portalOpt = $this->option('portal');
|
||||
$isDryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$entities = $entityOpt === 'all' ? array_keys(self::ENTITY_CONFIG) : [$entityOpt];
|
||||
$portals = $portalOpt === 'all' ? array_keys(self::PORTAL_DB) : [$portalOpt];
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||
}
|
||||
|
||||
$totalUpdated = 0;
|
||||
$start = microtime(true);
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$config = self::ENTITY_CONFIG[$entity] ?? null;
|
||||
if (! $config) {
|
||||
$this->error("Unbekannte Entität: {$entity}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($portals as $portal) {
|
||||
$legacyDb = self::PORTAL_DB[$portal] ?? null;
|
||||
if (! $legacyDb) {
|
||||
$this->error("Unbekanntes Portal: {$portal}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$updated = $this->fixEntity($config, $portal, $legacyDb, $isDryRun);
|
||||
$totalUpdated += $updated;
|
||||
|
||||
$this->line(" [{$entity}] [{$portal}] → {$updated} Datensätze ".($isDryRun ? 'würden aktualisiert' : 'aktualisiert'));
|
||||
}
|
||||
}
|
||||
|
||||
$elapsed = round(microtime(true) - $start, 1);
|
||||
$this->newLine();
|
||||
$this->info("Gesamt: {$totalUpdated} Datensätze in {$elapsed}s.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function fixEntity(array $config, string $portal, string $legacyDb, bool $isDryRun): int
|
||||
{
|
||||
$newTable = $config['new_table'];
|
||||
$legacyTable = $config['legacy_table_name'];
|
||||
$legacyCreated = $config['legacy_created'];
|
||||
$legacyUpdated = $config['legacy_updated'];
|
||||
|
||||
if ($isDryRun) {
|
||||
// Nur zählen: wie viele Datensätze hätten falsche Timestamps?
|
||||
return (int) DB::selectOne("
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM {$newTable} n
|
||||
JOIN legacy_import_map m
|
||||
ON m.target_id = n.id
|
||||
AND m.target_table = '{$newTable}'
|
||||
AND m.legacy_portal = '{$portal}'
|
||||
JOIN `{$legacyDb}`.`{$legacyTable}` lc ON lc.id = m.legacy_id
|
||||
WHERE n.created_at != lc.{$legacyCreated}
|
||||
OR n.updated_at != lc.{$legacyUpdated}
|
||||
")->cnt ?? 0;
|
||||
}
|
||||
|
||||
return DB::affectingStatement("
|
||||
UPDATE {$newTable} n
|
||||
JOIN legacy_import_map m
|
||||
ON m.target_id = n.id
|
||||
AND m.target_table = '{$newTable}'
|
||||
AND m.legacy_portal = '{$portal}'
|
||||
JOIN `{$legacyDb}`.`{$legacyTable}` lc ON lc.id = m.legacy_id
|
||||
SET
|
||||
n.created_at = lc.{$legacyCreated},
|
||||
n.updated_at = lc.{$legacyUpdated}
|
||||
");
|
||||
}
|
||||
}
|
||||
144
app/Console/Commands/ImportLegacyData.php
Normal file
144
app/Console/Commands/ImportLegacyData.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Auth\UserRolePermissionSyncService;
|
||||
use App\Services\Import\CategoryImporter;
|
||||
use App\Services\Import\CompanyImporter;
|
||||
use App\Services\Import\ContactImporter;
|
||||
use App\Services\Import\ImportContext;
|
||||
use App\Services\Import\ImportResult;
|
||||
use App\Services\Import\PressReleaseImporter;
|
||||
use App\Services\Import\UserAssociationLinker;
|
||||
use App\Services\Import\UserImporter;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Legacy-Import-Master-Command (Phase 6 – Datenmigration).
|
||||
*
|
||||
* Verwendung:
|
||||
* php artisan legacy:import --source=presseecho --dry-run
|
||||
* php artisan legacy:import --source=businessportal24 --step=users
|
||||
* php artisan legacy:import --source=all --step=categories
|
||||
* php artisan legacy:import --source=all --step=link-associations ← NEU
|
||||
* php artisan legacy:import --source=presseecho --force
|
||||
*
|
||||
* Schritte (--step):
|
||||
* categories – Kategorien + Translations (einmalig, beide Portale identisch)
|
||||
* users – sf_guard_user + sf_guard_user_profile + Rollen
|
||||
* companies – company + company_user + responsible_company_user
|
||||
* contacts – contact
|
||||
* press-releases – press_release + press_release_image + press_release_contact
|
||||
* link-associations – User↔Kontakt direkt verknüpfen (via Firma-Zugehörigkeit)
|
||||
* all – alle Schritte in korrekter Reihenfolge (Standard)
|
||||
*/
|
||||
class ImportLegacyData extends Command
|
||||
{
|
||||
protected $signature = 'legacy:import
|
||||
{--source=all : Portal (presseecho|businessportal24|all)}
|
||||
{--step=all : Schritt (categories|users|companies|contacts|press-releases|link-associations|all)}
|
||||
{--dry-run : Nichts schreiben, nur zählen}
|
||||
{--force : Bereits importierte Datensätze erneut verarbeiten}
|
||||
{--chunk-size=500 : Batch-Größe (Debugging)}';
|
||||
|
||||
protected $description = 'Legacy-Daten aus Presseecho / Businessportal24 in die neue DB importieren.';
|
||||
|
||||
private const STEPS = [
|
||||
'categories',
|
||||
'users',
|
||||
'companies',
|
||||
'contacts',
|
||||
'press-releases',
|
||||
'link-associations',
|
||||
];
|
||||
|
||||
private const PORTALS = ['presseecho', 'businessportal24'];
|
||||
|
||||
public function handle(UserRolePermissionSyncService $roleSync): int
|
||||
{
|
||||
$source = $this->option('source');
|
||||
$step = $this->option('step');
|
||||
$isDryRun = (bool) $this->option('dry-run');
|
||||
$isForce = (bool) $this->option('force');
|
||||
|
||||
$portals = $source === 'all' ? self::PORTALS : [$source];
|
||||
$steps = $step === 'all' ? self::STEPS : [$step];
|
||||
|
||||
if (! $isDryRun && ! $this->option('force')) {
|
||||
$this->warn('WICHTIG: Dieser Command schreibt Daten in die Produktions-DB.');
|
||||
$this->line(' Portale: '.implode(', ', $portals));
|
||||
$this->line(' Schritte: '.implode(', ', $steps));
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->confirm('Import starten?')) {
|
||||
$this->info('Abgebrochen.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||
}
|
||||
|
||||
$totalStart = microtime(true);
|
||||
|
||||
foreach ($steps as $currentStep) {
|
||||
// link-associations arbeitet auf der neuen DB → einmalig, portal-unabhängig
|
||||
if ($currentStep === 'link-associations') {
|
||||
$ctx = new ImportContext('all', $isDryRun, $isForce);
|
||||
$this->info('▶ Schritt [link-associations] (beide Portale, neue DB)');
|
||||
$start = microtime(true);
|
||||
$result = app(UserAssociationLinker::class)->run($ctx);
|
||||
$elapsed = round(microtime(true) - $start, 1);
|
||||
$this->line(" ✓ {$result->summary()} ({$elapsed}s)");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kategorien sind portal-übergreifend → nur einmal
|
||||
$stepPortals = ($currentStep === 'categories') ? [$portals[0]] : $portals;
|
||||
|
||||
foreach ($stepPortals as $portal) {
|
||||
$ctx = new ImportContext($portal, $isDryRun, $isForce);
|
||||
|
||||
$this->info("▶ Schritt [{$currentStep}] Portal [{$portal}]");
|
||||
$start = microtime(true);
|
||||
|
||||
$result = $this->runStep($currentStep, $ctx, $roleSync);
|
||||
|
||||
$elapsed = round(microtime(true) - $start, 1);
|
||||
$this->line(" ✓ {$result->summary()} ({$elapsed}s)");
|
||||
|
||||
foreach (array_slice($result->errors(), 0, 10) as $err) {
|
||||
$this->warn(" ! {$err}");
|
||||
}
|
||||
|
||||
if (count($result->errors()) > 10) {
|
||||
$this->warn(' ! ... und '.(count($result->errors()) - 10).' weitere Fehler.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$total = round(microtime(true) - $totalStart, 1);
|
||||
$this->newLine();
|
||||
$this->info("Import abgeschlossen in {$total}s.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function runStep(
|
||||
string $step,
|
||||
ImportContext $ctx,
|
||||
UserRolePermissionSyncService $roleSync,
|
||||
): ImportResult {
|
||||
return match ($step) {
|
||||
'categories' => app(CategoryImporter::class)->run($ctx),
|
||||
'users' => app(UserImporter::class, ['roleSync' => $roleSync])->run($ctx),
|
||||
'companies' => app(CompanyImporter::class)->run($ctx),
|
||||
'contacts' => app(ContactImporter::class)->run($ctx),
|
||||
'press-releases' => app(PressReleaseImporter::class)->run($ctx),
|
||||
default => throw new \InvalidArgumentException("Unbekannter Schritt: {$step}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,21 +2,19 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Article as NewArticle;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Legacy\Presseecho\Article as LegacyArticle;
|
||||
use App\Models\Legacy\Presseecho\User as LegacyUser;
|
||||
// Import der Models für die NEUE Struktur
|
||||
use App\Models\User as NewUser;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
// Import der Models für die ALTE Struktur
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
// Import der Models für die NEUE Struktur
|
||||
use App\Models\User as NewUser;
|
||||
use App\Models\Article as NewArticle;
|
||||
use App\Models\Brand;
|
||||
|
||||
// Import der Models für die ALTE Struktur
|
||||
use App\Models\Legacy\Presseecho\User as LegacyUser;
|
||||
use App\Models\Legacy\Presseecho\Article as LegacyArticle;
|
||||
|
||||
class MigratePresseechoData extends Command
|
||||
class MigratePresseData extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
|
|
@ -41,8 +39,9 @@ class MigratePresseechoData extends Command
|
|||
|
||||
// Holen der Brand-ID für Presseecho aus der neuen DB
|
||||
$brand = Brand::where('domain', 'presseecho.de')->first();
|
||||
if (!$brand) {
|
||||
if (! $brand) {
|
||||
$this->error('Brand "presseecho.de" not found in the new database. Please seed brands first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +57,7 @@ class MigratePresseechoData extends Command
|
|||
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
|
||||
|
||||
$this->info('Migration for presseecho.de completed successfully!');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ class MigratePresseechoData extends Command
|
|||
// Prüfen, ob der Nutzer (anhand der E-Mail) bereits in der neuen DB existiert
|
||||
$existingUser = NewUser::where('email', $legacyUser->email)->first();
|
||||
|
||||
if (!$existingUser) {
|
||||
if (! $existingUser) {
|
||||
// Nutzer existiert noch nicht -> neu anlegen
|
||||
NewUser::create([
|
||||
'name' => $legacyUser->name,
|
||||
|
|
@ -104,8 +104,9 @@ class MigratePresseechoData extends Command
|
|||
// Den zugehörigen Nutzer in der NEUEN Datenbank finden
|
||||
$author = NewUser::where('email', $legacyArticle->author_email)->first(); // Annahme: Artikel hat eine Autoren-E-Mail
|
||||
|
||||
if (!$author) {
|
||||
if (! $author) {
|
||||
$this->warn("Skipping article ID {$legacyArticle->id}, author with email {$legacyArticle->author_email} not found.");
|
||||
|
||||
continue; // Artikel überspringen, wenn kein Autor gefunden wurde
|
||||
}
|
||||
|
||||
|
|
|
|||
45
app/Console/Commands/PurgeExpiredPressReleaseDrafts.php
Normal file
45
app/Console/Commands/PurgeExpiredPressReleaseDrafts.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden.
|
||||
* Schützt vor "Zombie-Drafts" in der DB – optional, konfigurierbar.
|
||||
*/
|
||||
class PurgeExpiredPressReleaseDrafts extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:purge-drafts
|
||||
{--days=180 : Entwürfe älter als X Tage archivieren}
|
||||
{--dry-run : Nur zählen, nichts ändern}';
|
||||
|
||||
protected $description = 'Archiviert inaktive PM-Entwürfe (älter als X Tage).';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$query = PressRelease::withoutGlobalScopes()
|
||||
->where('status', PressReleaseStatus::Draft->value)
|
||||
->where('updated_at', '<', $cutoff);
|
||||
|
||||
$count = $query->count();
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn("[DRY-RUN] {$count} Entwürfe würden archiviert. Kein tatsächlicher Vorgang.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$query->update(['status' => PressReleaseStatus::Archived->value]);
|
||||
|
||||
$this->info("PM-Entwürfe archiviert: {$count} Einträge.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
37
app/Console/Commands/PurgeMagicLinks.php
Normal file
37
app/Console/Commands/PurgeMagicLinks.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MagicLink;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Löscht verbrauchte oder abgelaufene Magic-Links, die älter als 30 Tage sind.
|
||||
* Läuft täglich via Scheduler.
|
||||
*/
|
||||
class PurgeMagicLinks extends Command
|
||||
{
|
||||
protected $signature = 'magic-links:purge {--days=30 : Links älter als X Tage löschen}';
|
||||
|
||||
protected $description = 'Löscht verbrauchte und abgelaufene Magic-Links.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$deleted = MagicLink::query()
|
||||
->where(function ($q) use ($cutoff): void {
|
||||
$q->where('expires_at', '<', $cutoff)
|
||||
->orWhere(function ($q) use ($cutoff): void {
|
||||
$q->whereNotNull('consumed_at')
|
||||
->where('consumed_at', '<', $cutoff);
|
||||
});
|
||||
})
|
||||
->delete();
|
||||
|
||||
$this->info("Magic-Links bereinigt: {$deleted} Einträge gelöscht.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
181
app/Console/Commands/ReportAdminSlowRequests.php
Normal file
181
app/Console/Commands/ReportAdminSlowRequests.php
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Admin\AdminSlowRequestReporter;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReportAdminSlowRequests extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'admin:slow-requests
|
||||
{--from= : Startzeitpunkt, z.B. 2026-04-29 oder 2026-04-29 08:00:00}
|
||||
{--to= : Endzeitpunkt}
|
||||
{--route= : Auf Route-Namen filtern}
|
||||
{--path= : Auf Pfad filtern}
|
||||
{--status= : Auf HTTP-Statuscode filtern}
|
||||
{--min-duration= : Mindestdauer in Millisekunden}
|
||||
{--top=10 : Anzahl Top-Zeilen pro Abschnitt}
|
||||
{--limit=25 : Anzahl Detailzeilen}
|
||||
{--file=* : Optionaler Logdatei-Pfad oder Glob}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Wertet Slow-Admin-Request-Logs aus.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(AdminSlowRequestReporter $reporter): int
|
||||
{
|
||||
$top = max(1, (int) $this->option('top'));
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$files = array_values(array_filter(array_map('strval', (array) $this->option('file'))));
|
||||
|
||||
$report = $reporter->report(
|
||||
filters: [
|
||||
'from' => $this->option('from') !== null ? (string) $this->option('from') : null,
|
||||
'to' => $this->option('to') !== null ? (string) $this->option('to') : null,
|
||||
'route' => $this->option('route') !== null ? (string) $this->option('route') : null,
|
||||
'path' => $this->option('path') !== null ? (string) $this->option('path') : null,
|
||||
'status' => $this->option('status') !== null ? (int) $this->option('status') : null,
|
||||
'min_duration_ms' => $this->option('min-duration') !== null ? (int) $this->option('min-duration') : null,
|
||||
],
|
||||
top: $top,
|
||||
limit: $limit,
|
||||
paths: $files !== [] ? $files : null,
|
||||
);
|
||||
|
||||
$this->info('Slow-Admin-Request-Report');
|
||||
$this->newLine();
|
||||
$this->line("Dateien: {$report['summary']['files']}");
|
||||
$this->line("Requests: {$report['summary']['total_requests']}");
|
||||
$this->line("Routen: {$report['summary']['unique_routes']}");
|
||||
$this->line("Durchschnitt Dauer: {$report['summary']['average_duration_ms']} ms");
|
||||
$this->line("Max. Dauer: {$report['summary']['max_duration_ms']} ms");
|
||||
$this->line("Durchschnitt DB-Zeit: {$report['summary']['average_database_time_ms']} ms");
|
||||
$this->line("Max. Query-Anzahl: {$report['summary']['max_query_count']}");
|
||||
|
||||
$this->renderTopTable('Top Routen', $report['top_routes']);
|
||||
$this->renderTopTable('Top Pfade', $report['top_paths']);
|
||||
$this->renderTopTable('Statuscodes', $report['status_codes']);
|
||||
$this->renderRequests('Langsamste Requests', $report['slowest_requests']);
|
||||
$this->renderRequests('Query-lastige Requests', $report['query_heavy_requests']);
|
||||
$this->renderSlowQueries($report['slow_queries']);
|
||||
$this->renderExplainPlans($report['explain_plans']);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function renderTopTable(string $title, array $rows): void
|
||||
{
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line($title);
|
||||
$this->table(
|
||||
['Wert', 'Requests', 'Ø Dauer', 'Max Dauer', 'Ø DB', 'Queries'],
|
||||
collect($rows)
|
||||
->map(fn (array $row): array => [
|
||||
$row['value'],
|
||||
$row['requests'],
|
||||
$row['average_duration_ms'].' ms',
|
||||
$row['max_duration_ms'].' ms',
|
||||
$row['average_database_time_ms'].' ms',
|
||||
$row['total_queries'],
|
||||
])
|
||||
->all()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function renderRequests(string $title, array $rows): void
|
||||
{
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line($title);
|
||||
$this->table(
|
||||
['Zeit', 'Route', 'Pfad', 'Status', 'Dauer', 'DB', 'Queries'],
|
||||
collect($rows)
|
||||
->map(fn (array $row): array => [
|
||||
$row['timestamp'],
|
||||
$row['route_name'],
|
||||
$row['path'],
|
||||
$row['status_code'],
|
||||
$row['duration_ms'].' ms',
|
||||
$row['database_time_ms'].' ms',
|
||||
$row['query_count'],
|
||||
])
|
||||
->all()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function renderSlowQueries(array $rows): void
|
||||
{
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line('Häufige Slow Queries');
|
||||
$this->table(
|
||||
['SQL', 'Vorkommen', 'Ø Zeit', 'Max Zeit'],
|
||||
collect($rows)
|
||||
->map(fn (array $row): array => [
|
||||
str($row['sql'])->limit(100)->toString(),
|
||||
$row['occurrences'],
|
||||
$row['average_time_ms'].' ms',
|
||||
$row['max_time_ms'].' ms',
|
||||
])
|
||||
->all()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function renderExplainPlans(array $rows): void
|
||||
{
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line('EXPLAIN Top Slow Queries');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->line(str($row['sql'])->limit(120)->toString());
|
||||
|
||||
if ($row['error'] !== null) {
|
||||
$this->warn((string) $row['error']);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
array_keys($row['plan'][0] ?? []),
|
||||
collect($row['plan'])->map(fn (array $planRow): array => array_values($planRow))->all()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
app/Console/Commands/ReportApiUsage.php
Normal file
92
app/Console/Commands/ReportApiUsage.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Api\ApiUsageReporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ReportApiUsage extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'api:usage-report
|
||||
{--from= : Startzeitpunkt, z.B. 2026-04-01 oder 2026-04-01 00:00:00}
|
||||
{--to= : Endzeitpunkt}
|
||||
{--user= : Auf User-ID filtern}
|
||||
{--status= : Auf HTTP-Statuscode filtern}
|
||||
{--top=10 : Anzahl Top-Zeilen pro Abschnitt}
|
||||
{--no-report : Keinen JSON-Report schreiben}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Erstellt einen Report aus den protokollierten API-Usage-Logs.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(ApiUsageReporter $reporter): int
|
||||
{
|
||||
$top = max(1, (int) $this->option('top'));
|
||||
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
|
||||
$statusCode = $this->option('status') !== null ? (int) $this->option('status') : null;
|
||||
|
||||
$report = $reporter->report(
|
||||
from: $this->option('from') !== null ? (string) $this->option('from') : null,
|
||||
to: $this->option('to') !== null ? (string) $this->option('to') : null,
|
||||
userId: $userId,
|
||||
statusCode: $statusCode,
|
||||
top: $top,
|
||||
);
|
||||
|
||||
$this->info('API-Usage-Report');
|
||||
$this->newLine();
|
||||
$this->line("Requests: {$report['summary']['total_requests']}");
|
||||
$this->line("Eindeutige User: {$report['summary']['unique_users']}");
|
||||
$this->line("Eindeutige Tokens: {$report['summary']['unique_tokens']}");
|
||||
$this->line("2xx: {$report['summary']['successful_requests']}");
|
||||
$this->line("4xx: {$report['summary']['client_error_requests']}");
|
||||
$this->line("5xx: {$report['summary']['server_error_requests']}");
|
||||
$this->line("Durchschnitt Dauer: {$report['summary']['average_duration_ms']} ms");
|
||||
|
||||
$this->renderRows('Top Pfade', ['Pfad', 'Requests'], $report['top_paths']);
|
||||
$this->renderRows('Statuscodes', ['Status', 'Requests'], $report['status_codes']);
|
||||
$this->renderRows('Top User', ['User-ID', 'Requests'], $report['top_users']);
|
||||
$this->renderRows('Top Tokens', ['Token-ID', 'Requests'], $report['top_tokens']);
|
||||
|
||||
if (! (bool) $this->option('no-report')) {
|
||||
$path = 'migration/api-usage-'.now()->format('Ymd-His').'.json';
|
||||
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$this->newLine();
|
||||
$this->line("Report geschrieben: storage/app/private/{$path}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
* @param list<array{value: mixed, requests: int}> $rows
|
||||
*/
|
||||
private function renderRows(string $title, array $headers, array $rows): void
|
||||
{
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line($title);
|
||||
$this->table(
|
||||
$headers,
|
||||
collect($rows)
|
||||
->map(fn (array $row): array => [$row['value'], $row['requests']])
|
||||
->all()
|
||||
);
|
||||
}
|
||||
}
|
||||
92
app/Console/Commands/ReportLegacyApiCustomers.php
Normal file
92
app/Console/Commands/ReportLegacyApiCustomers.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Api\LegacyApiCustomerReporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ReportLegacyApiCustomers extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'api:legacy-customers-report
|
||||
{--portal=all : Portal (presseecho|businessportal24|both|all)}
|
||||
{--classification=all : Filter (eligible|needs_review|blocked|all)}
|
||||
{--no-report : Keinen JSON-Report schreiben}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Ermittelt Legacy-API-Kunden aus migrierten Daten und letzter bezahlter Rechnung.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(LegacyApiCustomerReporter $reporter): int
|
||||
{
|
||||
$portal = (string) $this->option('portal');
|
||||
$classification = (string) $this->option('classification');
|
||||
|
||||
if (! in_array($portal, ['presseecho', 'businessportal24', 'both', 'all'], true)) {
|
||||
$this->error("Unbekanntes Portal: {$portal}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! in_array($classification, ['eligible', 'needs_review', 'blocked', 'all'], true)) {
|
||||
$this->error("Unbekannte Klassifizierung: {$classification}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$report = $reporter->report($portal);
|
||||
$customers = collect($report['customers']);
|
||||
|
||||
if ($classification !== 'all') {
|
||||
$customers = $customers
|
||||
->where('classification', $classification)
|
||||
->values();
|
||||
}
|
||||
|
||||
$this->info('Legacy-API-Kundenreport');
|
||||
$this->newLine();
|
||||
$this->line("Portal: {$portal}");
|
||||
$this->line("Kandidaten: {$report['summary']['total_candidates']}");
|
||||
$this->line("Freigabefähig: {$report['summary']['eligible']}");
|
||||
$this->line("Manuell prüfen: {$report['summary']['needs_review']}");
|
||||
$this->line("Gesperrt: {$report['summary']['blocked']}");
|
||||
|
||||
if ($customers->isNotEmpty()) {
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['ID', 'E-Mail', 'Portal', 'Letzte Rechnung', 'Status', 'Klassifizierung'],
|
||||
$customers
|
||||
->take(25)
|
||||
->map(fn (array $customer): array => [
|
||||
$customer['user_id'],
|
||||
$customer['email'],
|
||||
$customer['portal'],
|
||||
$customer['latest_legacy_invoice']['number'] ?? '-',
|
||||
$customer['latest_legacy_invoice']['status'] ?? '-',
|
||||
$customer['classification'],
|
||||
])
|
||||
->all()
|
||||
);
|
||||
}
|
||||
|
||||
if (! (bool) $this->option('no-report')) {
|
||||
$path = 'migration/legacy-api-customers-'.now()->format('Ymd-His').'.json';
|
||||
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$this->newLine();
|
||||
$this->line("Report geschrieben: storage/app/private/{$path}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
106
app/Console/Commands/SendGoLiveMails.php
Normal file
106
app/Console/Commands/SendGoLiveMails.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\GoLivePasswordReset;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
|
||||
/**
|
||||
* Sendet Go-Live-Passwort-Reset-Mails an alle aktiven User.
|
||||
*
|
||||
* Verwendung:
|
||||
* php artisan auth:send-go-live-mails --dry-run
|
||||
* php artisan auth:send-go-live-mails --portal=presseecho
|
||||
* php artisan auth:send-go-live-mails --limit=10
|
||||
* php artisan auth:send-go-live-mails --force
|
||||
*/
|
||||
class SendGoLiveMails extends Command
|
||||
{
|
||||
protected $signature = 'auth:send-go-live-mails
|
||||
{--dry-run : Nur Anzahl ausgeben, keine Mails senden}
|
||||
{--portal= : Nur User eines Portals (presseecho|businessportal24|both)}
|
||||
{--limit=0 : Maximale Anzahl (0 = alle)}
|
||||
{--force : Bestätigung überspringen}';
|
||||
|
||||
protected $description = 'Sendet Go-Live-Passwort-Reset-Mails an alle aktiven Benutzer.';
|
||||
|
||||
private const EXPIRES_IN_MINUTES = 60;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$portal = $this->option('portal');
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$query = User::query()
|
||||
->where('is_active', true)
|
||||
->whereNotNull('email')
|
||||
->when($portal, fn ($q) => $q->where('portal', $portal));
|
||||
|
||||
$total = $query->count();
|
||||
$this->info("Aktive Benutzer gefunden: {$total}");
|
||||
|
||||
if ($portal) {
|
||||
$this->line(" → Portal-Filter: {$portal}");
|
||||
}
|
||||
|
||||
if ($limit > 0) {
|
||||
$this->line(" → Limit: {$limit}");
|
||||
$query->limit($limit);
|
||||
$total = min($total, $limit);
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn("[DRY-RUN] Es würden {$total} Mails versendet. Kein tatsächlicher Versand.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm("Jetzt {$total} Go-Live-Mails senden?")) {
|
||||
$this->info('Abgebrochen.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
$query->each(function (User $user) use (&$sent, &$failed, $bar): void {
|
||||
try {
|
||||
$token = Password::broker()->createToken($user);
|
||||
$resetUrl = url(route('password.reset', [
|
||||
'token' => $token,
|
||||
'email' => $user->email,
|
||||
], false));
|
||||
|
||||
Mail::to($user->email)->send(
|
||||
new GoLivePasswordReset($user, $resetUrl, self::EXPIRES_IN_MINUTES)
|
||||
);
|
||||
|
||||
$sent++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->newLine();
|
||||
$this->error("Fehler für {$user->email}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info("Versendet: {$sent}");
|
||||
|
||||
if ($failed > 0) {
|
||||
$this->warn("Fehlgeschlagen: {$failed}");
|
||||
}
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
160
app/Console/Commands/SyncCompanyLogos.php
Normal file
160
app/Console/Commands/SyncCompanyLogos.php
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SyncCompanyLogos extends Command
|
||||
{
|
||||
protected $signature = 'legacy:sync-company-logos
|
||||
{--portal=all : Portal (presseecho|businessportal24|all)}
|
||||
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
|
||||
{--force : Ziel-Dateien erneut überschreiben}';
|
||||
|
||||
protected $description = 'Kopiert referenzierte Legacy-Firmenlogos in einen sauberen Storage-Pfad und aktualisiert companies.logo_path.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$portals = $this->selectedPortals();
|
||||
|
||||
if ($portals === []) {
|
||||
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$force = (bool) $this->option('force');
|
||||
$totals = [
|
||||
'referenced' => 0,
|
||||
'copied' => 0,
|
||||
'updated' => 0,
|
||||
'already_synced' => 0,
|
||||
'missing' => 0,
|
||||
'unused' => 0,
|
||||
];
|
||||
|
||||
foreach ($portals as $portal) {
|
||||
$stats = $this->syncPortal($portal, $dryRun, $force);
|
||||
|
||||
foreach ($totals as $key => $value) {
|
||||
$totals[$key] = $value + $stats[$key];
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'%s: referenziert %d, kopiert %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d',
|
||||
$portal,
|
||||
$stats['referenced'],
|
||||
$stats['copied'],
|
||||
$stats['updated'],
|
||||
$stats['already_synced'],
|
||||
$stats['missing'],
|
||||
$stats['unused'],
|
||||
));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Gesamt: referenziert %d, kopiert %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d%s',
|
||||
$totals['referenced'],
|
||||
$totals['copied'],
|
||||
$totals['updated'],
|
||||
$totals['already_synced'],
|
||||
$totals['missing'],
|
||||
$totals['unused'],
|
||||
$dryRun ? ' (Dry-Run)' : '',
|
||||
));
|
||||
|
||||
return $totals['missing'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function selectedPortals(): array
|
||||
{
|
||||
$portal = (string) $this->option('portal');
|
||||
|
||||
return match ($portal) {
|
||||
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
|
||||
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{referenced:int,copied:int,updated:int,already_synced:int,missing:int,unused:int}
|
||||
*/
|
||||
private function syncPortal(string $portal, bool $dryRun, bool $force): array
|
||||
{
|
||||
$sourceDirectory = "{$portal}/company";
|
||||
$allSourceFiles = collect(Storage::disk('public')->files($sourceDirectory))
|
||||
->mapWithKeys(fn (string $path): array => [basename($path) => $path]);
|
||||
|
||||
$referencedFilenames = [];
|
||||
$stats = [
|
||||
'referenced' => 0,
|
||||
'copied' => 0,
|
||||
'updated' => 0,
|
||||
'already_synced' => 0,
|
||||
'missing' => 0,
|
||||
'unused' => 0,
|
||||
];
|
||||
|
||||
Company::withoutGlobalScopes()
|
||||
->where('legacy_portal', $portal)
|
||||
->whereNotNull('logo_path')
|
||||
->select(['id', 'legacy_portal', 'logo_path'])
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($companies) use ($allSourceFiles, $dryRun, $force, $portal, &$referencedFilenames, &$stats): void {
|
||||
foreach ($companies as $company) {
|
||||
$stats['referenced']++;
|
||||
|
||||
$sourceFilename = basename((string) $company->logo_path);
|
||||
$referencedFilenames[$sourceFilename] = true;
|
||||
|
||||
if (Str::startsWith((string) $company->logo_path, 'company-logos/')) {
|
||||
$stats['already_synced']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourcePath = $allSourceFiles->get($sourceFilename);
|
||||
|
||||
if (! $sourcePath) {
|
||||
$stats['missing']++;
|
||||
$this->warn("Fehlt: {$portal}/company/{$sourceFilename} (Company #{$company->id})");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$destinationPath = "company-logos/{$portal}/{$company->id}/{$sourceFilename}";
|
||||
|
||||
if (! $dryRun && ($force || ! Storage::disk('public')->exists($destinationPath))) {
|
||||
Storage::disk('public')->copy($sourcePath, $destinationPath);
|
||||
$stats['copied']++;
|
||||
} elseif ($dryRun || Storage::disk('public')->exists($destinationPath)) {
|
||||
$stats['copied'] += $dryRun ? 1 : 0;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$company->forceFill([
|
||||
'logo_path' => $destinationPath,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$stats['updated']++;
|
||||
}
|
||||
});
|
||||
|
||||
$stats['unused'] = $allSourceFiles->keys()
|
||||
->reject(fn (string $filename): bool => isset($referencedFilenames[$filename]))
|
||||
->count();
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
235
app/Console/Commands/SyncPressReleaseImages.php
Normal file
235
app/Console/Commands/SyncPressReleaseImages.php
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Services\Image\ImageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SyncPressReleaseImages extends Command
|
||||
{
|
||||
protected $signature = 'legacy:sync-press-release-images
|
||||
{--portal=all : Portal (presseecho|businessportal24|all)}
|
||||
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
|
||||
{--force : Ziel-Dateien erneut überschreiben}
|
||||
{--skip-variants : Varianten nicht regenerieren (nur Datei-Kopie + DB-Update)}
|
||||
{--limit=0 : Maximal N Bilder pro Portal verarbeiten (0 = alle)}';
|
||||
|
||||
protected $description = 'Kopiert referenzierte Legacy-PM-Bilder in den finalen Storage-Pfad, generiert Varianten und aktualisiert press_release_images.';
|
||||
|
||||
public function __construct(private readonly ImageService $imageService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$portals = $this->selectedPortals();
|
||||
|
||||
if ($portals === []) {
|
||||
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$force = (bool) $this->option('force');
|
||||
$skipVariants = (bool) $this->option('skip-variants');
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
|
||||
$totals = [
|
||||
'referenced' => 0,
|
||||
'copied' => 0,
|
||||
'variants_generated' => 0,
|
||||
'updated' => 0,
|
||||
'already_synced' => 0,
|
||||
'missing' => 0,
|
||||
'unused' => 0,
|
||||
];
|
||||
|
||||
foreach ($portals as $portal) {
|
||||
$stats = $this->syncPortal($portal, $dryRun, $force, $skipVariants, $limit);
|
||||
|
||||
foreach ($totals as $key => $value) {
|
||||
$totals[$key] = $value + $stats[$key];
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'%s: referenziert %d, kopiert %d, Varianten %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d',
|
||||
$portal,
|
||||
$stats['referenced'],
|
||||
$stats['copied'],
|
||||
$stats['variants_generated'],
|
||||
$stats['updated'],
|
||||
$stats['already_synced'],
|
||||
$stats['missing'],
|
||||
$stats['unused'],
|
||||
));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Gesamt: referenziert %d, kopiert %d, Varianten %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d%s',
|
||||
$totals['referenced'],
|
||||
$totals['copied'],
|
||||
$totals['variants_generated'],
|
||||
$totals['updated'],
|
||||
$totals['already_synced'],
|
||||
$totals['missing'],
|
||||
$totals['unused'],
|
||||
$dryRun ? ' (Dry-Run)' : '',
|
||||
));
|
||||
|
||||
return $totals['missing'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function selectedPortals(): array
|
||||
{
|
||||
$portal = (string) $this->option('portal');
|
||||
|
||||
return match ($portal) {
|
||||
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
|
||||
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{referenced:int,copied:int,variants_generated:int,updated:int,already_synced:int,missing:int,unused:int}
|
||||
*/
|
||||
private function syncPortal(string $portal, bool $dryRun, bool $force, bool $skipVariants, int $limit): array
|
||||
{
|
||||
$sourceDirectory = "{$portal}/press_release";
|
||||
$allSourceFiles = collect(Storage::disk('public')->files($sourceDirectory))
|
||||
->mapWithKeys(fn (string $path): array => [basename($path) => $path]);
|
||||
|
||||
$referencedFilenames = [];
|
||||
$stats = [
|
||||
'referenced' => 0,
|
||||
'copied' => 0,
|
||||
'variants_generated' => 0,
|
||||
'updated' => 0,
|
||||
'already_synced' => 0,
|
||||
'missing' => 0,
|
||||
'unused' => 0,
|
||||
];
|
||||
|
||||
$query = PressReleaseImage::withoutGlobalScopes()
|
||||
->where('legacy_portal', $portal)
|
||||
->whereNotNull('path')
|
||||
->select(['id', 'press_release_id', 'legacy_portal', 'path', 'variants'])
|
||||
->orderBy('id');
|
||||
|
||||
$processed = 0;
|
||||
$reachLimit = false;
|
||||
|
||||
$query->chunkById(500, function ($images) use (
|
||||
$allSourceFiles, $dryRun, $force, $skipVariants, $portal, $limit,
|
||||
&$referencedFilenames, &$stats, &$processed, &$reachLimit,
|
||||
): bool {
|
||||
foreach ($images as $image) {
|
||||
if ($reachLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stats['referenced']++;
|
||||
$sourceFilename = basename((string) $image->path);
|
||||
$referencedFilenames[$sourceFilename] = true;
|
||||
|
||||
if (Str::startsWith((string) $image->path, 'press-releases/')) {
|
||||
$stats['already_synced']++;
|
||||
|
||||
if (! $skipVariants && (! is_array($image->variants) || $image->variants === [])) {
|
||||
if (! $dryRun) {
|
||||
$variants = $this->imageService->generateMissingPressReleaseVariants($image->path);
|
||||
|
||||
if ($variants !== []) {
|
||||
$image->forceFill(['variants' => $variants])->save();
|
||||
$stats['variants_generated']++;
|
||||
$stats['updated']++;
|
||||
}
|
||||
} else {
|
||||
$stats['variants_generated']++;
|
||||
}
|
||||
}
|
||||
|
||||
$processed++;
|
||||
|
||||
if ($limit > 0 && $processed >= $limit) {
|
||||
$reachLimit = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourcePath = $allSourceFiles->get($sourceFilename);
|
||||
|
||||
if (! $sourcePath) {
|
||||
$stats['missing']++;
|
||||
$this->warn("Fehlt: {$portal}/press_release/{$sourceFilename} (Image #{$image->id})");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$destinationPath = "press-releases/{$image->press_release_id}/images/{$sourceFilename}";
|
||||
|
||||
if (! $dryRun) {
|
||||
if ($force || ! Storage::disk('public')->exists($destinationPath)) {
|
||||
Storage::disk('public')->copy($sourcePath, $destinationPath);
|
||||
$stats['copied']++;
|
||||
}
|
||||
} else {
|
||||
$stats['copied']++;
|
||||
}
|
||||
|
||||
$variants = [];
|
||||
if (! $skipVariants && ! $dryRun) {
|
||||
$variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath);
|
||||
|
||||
if ($variants !== []) {
|
||||
$stats['variants_generated']++;
|
||||
}
|
||||
} elseif (! $skipVariants && $dryRun) {
|
||||
$stats['variants_generated']++;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$size = @getimagesize(Storage::disk('public')->path($destinationPath)) ?: [null, null];
|
||||
|
||||
$image->forceFill([
|
||||
'disk' => 'public',
|
||||
'path' => $destinationPath,
|
||||
'variants' => $variants !== [] ? $variants : $image->variants,
|
||||
'width' => is_int($size[0] ?? null) ? $size[0] : $image->width,
|
||||
'height' => is_int($size[1] ?? null) ? $size[1] : $image->height,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$stats['updated']++;
|
||||
$processed++;
|
||||
|
||||
if ($limit > 0 && $processed >= $limit) {
|
||||
$reachLimit = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$stats['unused'] = $allSourceFiles->keys()
|
||||
->reject(fn (string $filename): bool => isset($referencedFilenames[$filename]))
|
||||
->count();
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
408
app/Console/Commands/VerifyLegacyImport.php
Normal file
408
app/Console/Commands/VerifyLegacyImport.php
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class VerifyLegacyImport extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'legacy:verify
|
||||
{--portal=all : Portal (presseecho|businessportal24|all)}
|
||||
{--skip-legacy : Legacy-DB-Checks überspringen}
|
||||
{--no-report : Keinen JSON-Report schreiben}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Verifiziert den Legacy-Import und schreibt einen maschinenlesbaren Report.';
|
||||
|
||||
private const PORTAL_CONNECTIONS = [
|
||||
'presseecho' => 'mysql_presseecho',
|
||||
'businessportal24' => 'mysql_businessportal',
|
||||
];
|
||||
|
||||
private const ENTITY_MAP = [
|
||||
'users' => [
|
||||
'legacy_table' => 'sf_guard_user',
|
||||
'target_table' => 'users',
|
||||
'target_column' => 'legacy_id',
|
||||
],
|
||||
'companies' => [
|
||||
'legacy_table' => 'company',
|
||||
'target_table' => 'companies',
|
||||
'target_column' => 'legacy_id',
|
||||
],
|
||||
'contacts' => [
|
||||
'legacy_table' => 'contact',
|
||||
'target_table' => 'contacts',
|
||||
'target_column' => 'legacy_id',
|
||||
],
|
||||
'categories' => [
|
||||
'legacy_table' => 'category',
|
||||
'target_table' => 'categories',
|
||||
'target_column' => 'legacy_id',
|
||||
],
|
||||
'press_releases' => [
|
||||
'legacy_table' => 'press_release',
|
||||
'target_table' => 'press_releases',
|
||||
'target_column' => 'legacy_id',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$portalOption = (string) $this->option('portal');
|
||||
$skipLegacy = (bool) $this->option('skip-legacy');
|
||||
|
||||
$portals = $portalOption === 'all'
|
||||
? array_keys(self::PORTAL_CONNECTIONS)
|
||||
: [$portalOption];
|
||||
|
||||
if (array_diff($portals, array_keys(self::PORTAL_CONNECTIONS)) !== []) {
|
||||
$this->error("Unbekanntes Portal: {$portalOption}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$report = [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'portal' => $portalOption,
|
||||
'skip_legacy' => $skipLegacy,
|
||||
'checks' => [],
|
||||
'summary' => [
|
||||
'failures' => 0,
|
||||
'warnings' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
$this->info('Legacy-Import-Verifikation');
|
||||
$this->newLine();
|
||||
|
||||
if (! $skipLegacy) {
|
||||
$this->verifyLegacyCounts($report, $portals);
|
||||
$this->verifyLegacyInvoices($report, $portals);
|
||||
} else {
|
||||
$this->warn('Legacy-DB-Checks werden übersprungen.');
|
||||
}
|
||||
|
||||
$this->verifyImportMapTargets($report, $portals);
|
||||
$this->verifyTargetForeignKeys($report);
|
||||
$this->verifyUserMerges($report);
|
||||
$this->verifyGrandfatheredSubscriptions($report);
|
||||
|
||||
$this->renderSummary($report);
|
||||
|
||||
if (! (bool) $this->option('no-report')) {
|
||||
$path = 'migration/verify-'.now()->format('Ymd-His').'.json';
|
||||
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$this->line("Report geschrieben: storage/app/private/{$path}");
|
||||
}
|
||||
|
||||
return $report['summary']['failures'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
* @param list<string> $portals
|
||||
*/
|
||||
private function verifyLegacyCounts(array &$report, array $portals): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($portals as $portal) {
|
||||
foreach (self::ENTITY_MAP as $entity => $config) {
|
||||
$legacyCount = $this->legacyCount($portal, $config['legacy_table']);
|
||||
$targetCount = DB::table($config['target_table'])
|
||||
->where('legacy_portal', $portal)
|
||||
->whereNotNull($config['target_column'])
|
||||
->count();
|
||||
$mappedCount = DB::table('legacy_import_map')
|
||||
->where('legacy_portal', $portal)
|
||||
->where('legacy_table', $config['legacy_table'])
|
||||
->where('target_table', $config['target_table'])
|
||||
->count();
|
||||
|
||||
$rows[] = [
|
||||
'portal' => $portal,
|
||||
'entity' => $entity,
|
||||
'legacy_count' => $legacyCount,
|
||||
'target_count' => $targetCount,
|
||||
'mapped_count' => $mappedCount,
|
||||
'delta' => $legacyCount - $targetCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->addCheck($report, 'legacy_counts', 'ok', 'Legacy- und Ziel-Counts erfasst.', $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
* @param list<string> $portals
|
||||
*/
|
||||
private function verifyLegacyInvoices(array &$report, array $portals): void
|
||||
{
|
||||
$rows = [];
|
||||
$failures = 0;
|
||||
|
||||
foreach ($portals as $portal) {
|
||||
$legacy = DB::connection(self::PORTAL_CONNECTIONS[$portal])
|
||||
->table('invoice')
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as amount')
|
||||
->first();
|
||||
|
||||
$target = DB::table('legacy_invoices')
|
||||
->where('legacy_portal', $portal)
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount_cents), 0) as amount_cents')
|
||||
->first();
|
||||
|
||||
$legacyAmountCents = (int) round((float) $legacy->amount * 100);
|
||||
$targetAmountCents = (int) $target->amount_cents;
|
||||
$countDelta = (int) $legacy->count - (int) $target->count;
|
||||
$amountDelta = $legacyAmountCents - $targetAmountCents;
|
||||
|
||||
if ($countDelta !== 0 || $amountDelta !== 0) {
|
||||
$failures++;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'portal' => $portal,
|
||||
'legacy_count' => (int) $legacy->count,
|
||||
'target_count' => (int) $target->count,
|
||||
'count_delta' => $countDelta,
|
||||
'legacy_amount_cents' => $legacyAmountCents,
|
||||
'target_amount_cents' => $targetAmountCents,
|
||||
'amount_delta_cents' => $amountDelta,
|
||||
];
|
||||
}
|
||||
|
||||
$this->addCheck(
|
||||
$report,
|
||||
'legacy_invoices',
|
||||
$failures === 0 ? 'ok' : 'fail',
|
||||
$failures === 0 ? 'Legacy-Rechnungen stimmen mit dem Archiv überein.' : 'Legacy-Rechnungsarchiv weicht von der Quelle ab.',
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
* @param list<string> $portals
|
||||
*/
|
||||
private function verifyImportMapTargets(array &$report, array $portals): void
|
||||
{
|
||||
$rows = [];
|
||||
$failures = 0;
|
||||
|
||||
foreach (self::ENTITY_MAP as $entity => $config) {
|
||||
foreach ($portals as $portal) {
|
||||
$missingTargets = DB::table('legacy_import_map as map')
|
||||
->leftJoin($config['target_table'].' as target', 'target.id', '=', 'map.target_id')
|
||||
->where('map.legacy_portal', $portal)
|
||||
->where('map.legacy_table', $config['legacy_table'])
|
||||
->where('map.target_table', $config['target_table'])
|
||||
->whereNull('target.id')
|
||||
->count();
|
||||
|
||||
if ($missingTargets > 0) {
|
||||
$failures++;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'portal' => $portal,
|
||||
'entity' => $entity,
|
||||
'missing_targets' => $missingTargets,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->addCheck(
|
||||
$report,
|
||||
'import_map_targets',
|
||||
$failures === 0 ? 'ok' : 'fail',
|
||||
$failures === 0 ? 'Alle Import-Map-Ziele existieren.' : 'Import-Map enthält verwaiste Ziel-IDs.',
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
private function verifyTargetForeignKeys(array &$report): void
|
||||
{
|
||||
$checks = [
|
||||
'companies.owner_user_id' => DB::table('companies as c')
|
||||
->leftJoin('users as u', 'u.id', '=', 'c.owner_user_id')
|
||||
->whereNotNull('c.owner_user_id')
|
||||
->whereNull('u.id'),
|
||||
'contacts.company_id' => DB::table('contacts as c')
|
||||
->leftJoin('companies as co', 'co.id', '=', 'c.company_id')
|
||||
->whereNull('co.id'),
|
||||
'press_releases.user_id' => DB::table('press_releases as pr')
|
||||
->leftJoin('users as u', 'u.id', '=', 'pr.user_id')
|
||||
->whereNull('u.id'),
|
||||
'press_releases.company_id' => DB::table('press_releases as pr')
|
||||
->leftJoin('companies as c', 'c.id', '=', 'pr.company_id')
|
||||
->whereNotNull('pr.company_id')
|
||||
->whereNull('c.id'),
|
||||
'press_releases.category_id' => DB::table('press_releases as pr')
|
||||
->leftJoin('categories as c', 'c.id', '=', 'pr.category_id')
|
||||
->whereNull('c.id'),
|
||||
'company_user.company_id' => DB::table('company_user as pivot')
|
||||
->leftJoin('companies as c', 'c.id', '=', 'pivot.company_id')
|
||||
->whereNull('c.id'),
|
||||
'company_user.user_id' => DB::table('company_user as pivot')
|
||||
->leftJoin('users as u', 'u.id', '=', 'pivot.user_id')
|
||||
->whereNull('u.id'),
|
||||
'contact_user.contact_id' => DB::table('contact_user as pivot')
|
||||
->leftJoin('contacts as c', 'c.id', '=', 'pivot.contact_id')
|
||||
->whereNull('c.id'),
|
||||
'contact_user.user_id' => DB::table('contact_user as pivot')
|
||||
->leftJoin('users as u', 'u.id', '=', 'pivot.user_id')
|
||||
->whereNull('u.id'),
|
||||
'press_release_contact.press_release_id' => DB::table('press_release_contact as pivot')
|
||||
->leftJoin('press_releases as pr', 'pr.id', '=', 'pivot.press_release_id')
|
||||
->whereNull('pr.id'),
|
||||
'press_release_contact.contact_id' => DB::table('press_release_contact as pivot')
|
||||
->leftJoin('contacts as c', 'c.id', '=', 'pivot.contact_id')
|
||||
->whereNull('c.id'),
|
||||
'legacy_invoices.user_id' => DB::table('legacy_invoices as li')
|
||||
->leftJoin('users as u', 'u.id', '=', 'li.user_id')
|
||||
->whereNotNull('li.user_id')
|
||||
->whereNull('u.id'),
|
||||
];
|
||||
|
||||
$rows = [];
|
||||
$failures = 0;
|
||||
|
||||
foreach ($checks as $name => $query) {
|
||||
$count = $query->count();
|
||||
|
||||
if ($count > 0) {
|
||||
$failures++;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'relation' => $name,
|
||||
'dangling_count' => $count,
|
||||
];
|
||||
}
|
||||
|
||||
$this->addCheck(
|
||||
$report,
|
||||
'target_foreign_keys',
|
||||
$failures === 0 ? 'ok' : 'fail',
|
||||
$failures === 0 ? 'Keine verwaisten Ziel-Foreign-Keys gefunden.' : 'Zieltabellen enthalten verwaiste Foreign Keys.',
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
private function verifyUserMerges(array &$report): void
|
||||
{
|
||||
$bothUsers = DB::table('users')
|
||||
->where('portal', Portal::Both->value)
|
||||
->count();
|
||||
|
||||
$importMapsForBothUsers = DB::table('legacy_import_map as map')
|
||||
->join('users as users', 'users.id', '=', 'map.target_id')
|
||||
->where('map.legacy_table', 'sf_guard_user')
|
||||
->where('map.target_table', 'users')
|
||||
->where('users.portal', Portal::Both->value)
|
||||
->count();
|
||||
|
||||
$this->addCheck($report, 'user_merges', 'ok', 'Portalübergreifende User-Merges erfasst.', [
|
||||
'users_with_portal_both' => $bothUsers,
|
||||
'legacy_maps_to_portal_both_users' => $importMapsForBothUsers,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
private function verifyGrandfatheredSubscriptions(array &$report): void
|
||||
{
|
||||
$rows = DB::table('user_payment_options')
|
||||
->selectRaw('status, COUNT(*) as count, MIN(grandfathered_until) as earliest_grandfathered_until, MAX(grandfathered_until) as latest_grandfathered_until')
|
||||
->where('status', 'grandfathered')
|
||||
->groupBy('status')
|
||||
->get()
|
||||
->map(fn (object $row): array => [
|
||||
'status' => $row->status,
|
||||
'count' => (int) $row->count,
|
||||
'earliest_grandfathered_until' => $row->earliest_grandfathered_until,
|
||||
'latest_grandfathered_until' => $row->latest_grandfathered_until,
|
||||
])
|
||||
->all();
|
||||
|
||||
$this->addCheck($report, 'grandfathered_subscriptions', 'ok', 'Grandfathered Subscriptions erfasst.', $rows);
|
||||
}
|
||||
|
||||
private function legacyCount(string $portal, string $table): int
|
||||
{
|
||||
return DB::connection(self::PORTAL_CONNECTIONS[$portal])
|
||||
->table($table)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
* @param array<mixed> $details
|
||||
*/
|
||||
private function addCheck(array &$report, string $name, string $status, string $message, array $details): void
|
||||
{
|
||||
$report['checks'][$name] = [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'details' => $details,
|
||||
];
|
||||
|
||||
if ($status === 'fail') {
|
||||
$report['summary']['failures']++;
|
||||
}
|
||||
|
||||
if ($status === 'warning') {
|
||||
$report['summary']['warnings']++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
private function renderSummary(array $report): void
|
||||
{
|
||||
foreach ($report['checks'] as $name => $check) {
|
||||
$status = match ($check['status']) {
|
||||
'ok' => '<info>OK</info>',
|
||||
'warning' => '<comment>WARN</comment>',
|
||||
default => '<error>FAIL</error>',
|
||||
};
|
||||
|
||||
$this->line("{$status} {$name}: {$check['message']}");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['Status', 'Anzahl'],
|
||||
[
|
||||
['Fehler', $report['summary']['failures']],
|
||||
['Warnungen', $report['summary']['warnings']],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
12
app/Contracts/NewsletterSyncClient.php
Normal file
12
app/Contracts/NewsletterSyncClient.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Contracts;
|
||||
|
||||
use App\Models\NewsletterSubscription;
|
||||
|
||||
interface NewsletterSyncClient
|
||||
{
|
||||
public function subscribe(NewsletterSubscription $subscription): void;
|
||||
|
||||
public function unsubscribe(NewsletterSubscription $subscription): void;
|
||||
}
|
||||
17
app/Enums/CompanyType.php
Normal file
17
app/Enums/CompanyType.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CompanyType: string
|
||||
{
|
||||
case Company = 'company';
|
||||
case Agency = 'agency';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Company => 'Unternehmen',
|
||||
self::Agency => 'Agentur',
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/Enums/InvoiceStatus.php
Normal file
21
app/Enums/InvoiceStatus.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum InvoiceStatus: string
|
||||
{
|
||||
case Open = 'open';
|
||||
case Paid = 'paid';
|
||||
case Void = 'void';
|
||||
case Uncollectible = 'uncollectible';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Open => 'Offen',
|
||||
self::Paid => 'Bezahlt',
|
||||
self::Void => 'Storniert',
|
||||
self::Uncollectible => 'Uneinbringlich',
|
||||
};
|
||||
}
|
||||
}
|
||||
17
app/Enums/PaymentOptionType.php
Normal file
17
app/Enums/PaymentOptionType.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PaymentOptionType: string
|
||||
{
|
||||
case Recurring = 'recurring';
|
||||
case Onetime = 'onetime';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Recurring => 'Wiederkehrend',
|
||||
self::Onetime => 'Einmalig',
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/Enums/PaymentStatus.php
Normal file
21
app/Enums/PaymentStatus.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PaymentStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Succeeded = 'succeeded';
|
||||
case Failed = 'failed';
|
||||
case Refunded = 'refunded';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Ausstehend',
|
||||
self::Succeeded => 'Erfolgreich',
|
||||
self::Failed => 'Fehlgeschlagen',
|
||||
self::Refunded => 'Erstattet',
|
||||
};
|
||||
}
|
||||
}
|
||||
19
app/Enums/Portal.php
Normal file
19
app/Enums/Portal.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum Portal: string
|
||||
{
|
||||
case Presseecho = 'presseecho';
|
||||
case Businessportal24 = 'businessportal24';
|
||||
case Both = 'both';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Presseecho => 'Presseecho',
|
||||
self::Businessportal24 => 'Businessportal24',
|
||||
self::Both => 'Beide Portale',
|
||||
};
|
||||
}
|
||||
}
|
||||
23
app/Enums/PressReleaseStatus.php
Normal file
23
app/Enums/PressReleaseStatus.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PressReleaseStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Review = 'review';
|
||||
case Published = 'published';
|
||||
case Rejected = 'rejected';
|
||||
case Archived = 'archived';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'Entwurf',
|
||||
self::Review => 'In Pruefung',
|
||||
self::Published => 'Veroeffentlicht',
|
||||
self::Rejected => 'Abgelehnt',
|
||||
self::Archived => 'Archiviert',
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/Enums/RegistrationType.php
Normal file
21
app/Enums/RegistrationType.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum RegistrationType: string
|
||||
{
|
||||
case Agency = 'agency';
|
||||
case Company = 'company';
|
||||
case ApiUser = 'apiuser';
|
||||
case ExistingLegacy = 'existing_legacy';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Agency => 'Agentur',
|
||||
self::Company => 'Unternehmen',
|
||||
self::ApiUser => 'API-User',
|
||||
self::ExistingLegacy => 'Legacy-Bestand',
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/Enums/UserPaymentOptionStatus.php
Normal file
21
app/Enums/UserPaymentOptionStatus.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserPaymentOptionStatus: string
|
||||
{
|
||||
case Active = 'active';
|
||||
case PastDue = 'past_due';
|
||||
case Cancelled = 'cancelled';
|
||||
case Grandfathered = 'grandfathered';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Active => 'Aktiv',
|
||||
self::PastDue => 'Ueberfaellig',
|
||||
self::Cancelled => 'Gekuendigt',
|
||||
self::Grandfathered => 'Bestandsschutz',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -9,24 +9,24 @@ class ThemeHelper
|
|||
*/
|
||||
public static function getLogoPath(string $type = 'positive'): string
|
||||
{
|
||||
$theme = config('app.theme', 'pr-copilot');
|
||||
$theme = config('app.theme', 'portal');
|
||||
|
||||
$logoMap = [
|
||||
'portal' => [
|
||||
'positive' => 'img/logos/portal-logo-positive.svg',
|
||||
'negative' => 'img/logos/portal-logo-negative.svg'
|
||||
'negative' => 'img/logos/portal-logo-negative.svg',
|
||||
],
|
||||
'presseecho' => [
|
||||
'positive' => 'img/logos/presseecho-logo-positiv.svg',
|
||||
'negative' => 'img/logos/presseecho-logo-negativ.svg'
|
||||
'negative' => 'img/logos/presseecho-logo-negativ.svg',
|
||||
],
|
||||
'businessportal24' => [
|
||||
'positive' => 'img/logos/businessportal24-logo-positiv.svg',
|
||||
'negative' => 'img/logos/businessportal24-logo-negativ.svg'
|
||||
]
|
||||
'negative' => 'img/logos/businessportal24-logo-negativ.svg',
|
||||
],
|
||||
];
|
||||
|
||||
return $logoMap[$theme][$type] ?? $logoMap['pr-copilot'][$type];
|
||||
return $logoMap[$theme][$type] ?? $logoMap['portal'][$type];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -35,6 +35,7 @@ class ThemeHelper
|
|||
public static function getFaviconPath(): string
|
||||
{
|
||||
$theme = config('app.theme', 'portal');
|
||||
|
||||
return "img/favicons/{$theme}-favicon.ico";
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ class ThemeHelper
|
|||
public static function getThemeCssPath(): string
|
||||
{
|
||||
$theme = config('app.theme', 'portal');
|
||||
|
||||
return "resources/css/web/theme-{$theme}.css";
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ class ThemeHelper
|
|||
public static function getDomainConfig(): array
|
||||
{
|
||||
$theme = config('app.theme', 'portal');
|
||||
|
||||
return config("domains.domains.{$theme}", []);
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +65,7 @@ class ThemeHelper
|
|||
public static function getPrimaryColor(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['color_scheme']['primary'] ?? '#526266';
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +75,7 @@ class ThemeHelper
|
|||
public static function getSecondaryColor(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['color_scheme']['secondary'] ?? '#82a0a7';
|
||||
}
|
||||
|
||||
|
|
@ -80,22 +85,24 @@ class ThemeHelper
|
|||
public static function getFont(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['font'] ?? 'Montserrat';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get domain name for the current theme
|
||||
*/
|
||||
public static function getDomainName(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
return $config['domain_name'] ?? 'pr-copilot.test';
|
||||
|
||||
return $config['domain_name'] ?? 'presseportale.test';
|
||||
}
|
||||
|
||||
public static function getDomainUrl(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['url'] ?? config('app.url');
|
||||
}
|
||||
|
||||
|
|
@ -107,11 +114,11 @@ class ThemeHelper
|
|||
$theme = config('app.theme', 'portal');
|
||||
|
||||
$assetUrlMap = [
|
||||
'portal' => 'https://assets.pr-copilot.test',
|
||||
'portal' => 'https://assets.presseportale.test',
|
||||
'presseecho' => 'https://assets.presseecho.test',
|
||||
'businessportal24' => 'https://assets.businessportal24.test',
|
||||
];
|
||||
|
||||
return $assetUrlMap[$theme] ?? 'https://assets.pr-copilot.test';
|
||||
return $assetUrlMap[$theme] ?? 'https://assets.presseportale.test';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
app/Http/Controllers/Admin/DashboardController.php
Normal file
74
app/Http/Controllers/Admin/DashboardController.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\NewsletterSubscription;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use stdClass;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(): View
|
||||
{
|
||||
$stats = app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::DashboardStats, AdminPerformanceCache::StatsTtl, fn (): array => $this->stats());
|
||||
|
||||
return view('admin.dashboard', [
|
||||
'stats' => $stats,
|
||||
'recentPRs' => PressRelease::withoutGlobalScopes()
|
||||
->with(['company:id,name', 'user:id,name'])
|
||||
->latest('created_at')
|
||||
->limit(8)
|
||||
->get(['id', 'title', 'status', 'portal', 'company_id', 'user_id', 'created_at']),
|
||||
'pendingReviews' => PressRelease::withoutGlobalScopes()
|
||||
->with(['company:id,name'])
|
||||
->where('status', PressReleaseStatus::Review->value)
|
||||
->latest('created_at')
|
||||
->limit(5)
|
||||
->get(['id', 'title', 'company_id', 'portal', 'created_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{press_releases: array{total: int, published: int, review: int, draft: int}, companies: int, contacts: int, users: int, newsletter: int}
|
||||
*/
|
||||
private function stats(): array
|
||||
{
|
||||
$pressReleaseStats = PressRelease::withoutGlobalScopes()
|
||||
->toBase()
|
||||
->selectRaw('COUNT(*) as total')
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as published', [PressReleaseStatus::Published->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as review', [PressReleaseStatus::Review->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value])
|
||||
->first();
|
||||
|
||||
return [
|
||||
'press_releases' => $this->normalizePressReleaseStats($pressReleaseStats),
|
||||
'companies' => Company::withoutGlobalScopes()->count(),
|
||||
'contacts' => Contact::withoutGlobalScopes()->count(),
|
||||
'users' => User::query()->toBase()->count('*'),
|
||||
'newsletter' => NewsletterSubscription::withoutGlobalScopes()
|
||||
->where('is_confirmed', true)
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, published: int, review: int, draft: int}
|
||||
*/
|
||||
private function normalizePressReleaseStats(?stdClass $stats): array
|
||||
{
|
||||
return [
|
||||
'total' => (int) ($stats->total ?? 0),
|
||||
'published' => (int) ($stats->published ?? 0),
|
||||
'review' => (int) ($stats->review ?? 0),
|
||||
'draft' => (int) ($stats->draft ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Controllers/Admin/LeaveImpersonationController.php
Normal file
23
app/Http/Controllers/Admin/LeaveImpersonationController.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class LeaveImpersonationController extends Controller
|
||||
{
|
||||
public function __invoke(UserImpersonation $impersonation): RedirectResponse
|
||||
{
|
||||
$admin = $impersonation->stop();
|
||||
|
||||
if ($admin !== null) {
|
||||
return redirect()
|
||||
->route('admin.users.index')
|
||||
->with('status', __('Erfolgreich zurück zum Admin-Account gewechselt.'));
|
||||
}
|
||||
|
||||
return redirect()->route('me.dashboard');
|
||||
}
|
||||
}
|
||||
25
app/Http/Controllers/Api/V1/CategoryController.php
Normal file
25
app/Http/Controllers/Api/V1/CategoryController.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CategoryResource;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||
|
||||
$categories = Category::query()
|
||||
->with('translations')
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return CategoryResource::collection($categories);
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/Api/V1/CompanyController.php
Normal file
59
app/Http/Controllers/Api/V1/CompanyController.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CompanyResource;
|
||||
use App\Models\Company;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('companies:read'), 403);
|
||||
|
||||
$companies = Company::withoutGlobalScopes()
|
||||
->where(function ($query) use ($request): void {
|
||||
$query->where('owner_user_id', $request->user()->id)
|
||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||
|
||||
return CompanyResource::collection($companies);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $company): CompanyResource
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('companies:read'), 403);
|
||||
|
||||
$company = Company::withoutGlobalScopes()
|
||||
->whereKey($company)
|
||||
->where(function ($query) use ($request): void {
|
||||
$query->where('owner_user_id', $request->user()->id)
|
||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||
})
|
||||
->first();
|
||||
|
||||
abort_unless($company !== null, 403);
|
||||
|
||||
return CompanyResource::make($company);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function destroy(string $id)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
|
||||
use App\Models\NewsletterSubscription;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NewsletterSubscriptionController extends Controller
|
||||
{
|
||||
public function store(SubscribeNewsletterRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$subscription = NewsletterSubscription::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'portal' => $validated['portal'],
|
||||
'email' => mb_strtolower($validated['email']),
|
||||
],
|
||||
[
|
||||
'user_id' => $request->user()->id,
|
||||
'salutation_key' => $validated['salutation_key'] ?? null,
|
||||
'first_name' => $validated['first_name'] ?? null,
|
||||
'last_name' => $validated['last_name'] ?? null,
|
||||
'ip_address' => $request->ip(),
|
||||
'is_confirmed' => false,
|
||||
'confirmation_token' => Str::random(32),
|
||||
'subscribed_at' => now(),
|
||||
'unsubscribed_at' => null,
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Newsletter subscription created.',
|
||||
'data' => [
|
||||
'id' => $subscription->id,
|
||||
'portal' => $subscription->portal->value,
|
||||
'email' => $subscription->email,
|
||||
'is_confirmed' => $subscription->is_confirmed,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
165
app/Http/Controllers/Api/V1/PressReleaseController.php
Normal file
165
app/Http/Controllers/Api/V1/PressReleaseController.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
|
||||
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
|
||||
use App\Http\Resources\PressReleaseResource;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PressReleaseController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||
|
||||
$pressReleases = PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', $request->user()->id)
|
||||
->with(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
|
||||
->latest()
|
||||
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||
|
||||
return PressReleaseResource::collection($pressReleases);
|
||||
}
|
||||
|
||||
public function store(StorePressReleaseRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
|
||||
abort_unless($company !== null, 403);
|
||||
|
||||
$pressRelease = PressRelease::withoutGlobalScopes()->create([
|
||||
...$validated,
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'user_id' => $request->user()->id,
|
||||
'portal' => $company->portal->value,
|
||||
'slug' => $this->uniqueSlug(
|
||||
Str::slug($validated['title']),
|
||||
$company->portal->value,
|
||||
$validated['language'],
|
||||
),
|
||||
'status' => $validated['status'] ?? 'draft',
|
||||
]);
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $pressRelease): PressReleaseResource
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
);
|
||||
}
|
||||
|
||||
public function update(UpdatePressReleaseRequest $request, int $pressRelease): PressReleaseResource|JsonResponse
|
||||
{
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
|
||||
return response()->json([
|
||||
'message' => 'Only draft or rejected press releases may be edited.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$company = isset($validated['company_id'])
|
||||
? $this->findOwnedCompany((int) $validated['company_id'], $request)
|
||||
: $this->findOwnedCompany((int) $pressRelease->company_id, $request);
|
||||
abort_unless($company !== null, 403);
|
||||
|
||||
$language = $validated['language'] ?? $pressRelease->language;
|
||||
$title = $validated['title'] ?? $pressRelease->title;
|
||||
$portal = $company->portal->value;
|
||||
|
||||
$pressRelease->fill([
|
||||
...$validated,
|
||||
'portal' => $portal,
|
||||
]);
|
||||
|
||||
if (
|
||||
array_key_exists('title', $validated)
|
||||
|| array_key_exists('language', $validated)
|
||||
|| array_key_exists('company_id', $validated)
|
||||
) {
|
||||
$pressRelease->slug = $this->uniqueSlug(Str::slug($title), $portal, $language, $pressRelease->id);
|
||||
}
|
||||
|
||||
$pressRelease->save();
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $pressRelease): JsonResponse|Response
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
if ($pressRelease->status->value === 'published') {
|
||||
return response()->json([
|
||||
'message' => 'Published press releases cannot be deleted via API.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$pressRelease->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function findOwnedCompany(int $companyId, Request $request): ?Company
|
||||
{
|
||||
return Company::withoutGlobalScopes()
|
||||
->whereKey($companyId)
|
||||
->where(function ($query) use ($request): void {
|
||||
$query->where('owner_user_id', $request->user()->id)
|
||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->whereKey($pressReleaseId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function uniqueSlug(string $baseSlug, string $portal, string $language, ?int $excludeId = null): string
|
||||
{
|
||||
$slug = $baseSlug !== '' ? $baseSlug : Str::random(8);
|
||||
$candidate = $slug;
|
||||
$suffix = 2;
|
||||
|
||||
while (
|
||||
PressRelease::withoutGlobalScopes()
|
||||
->where('portal', $portal)
|
||||
->where('language', $language)
|
||||
->where('slug', $candidate)
|
||||
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
|
||||
->exists()
|
||||
) {
|
||||
$candidate = "{$slug}-{$suffix}";
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
121
app/Http/Controllers/Api/V1/PressReleaseImageController.php
Normal file
121
app/Http/Controllers/Api/V1/PressReleaseImageController.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
|
||||
use App\Http\Resources\PressReleaseImageResource;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Services\Image\ImageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
|
||||
class PressReleaseImageController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ImageService $imageService) {}
|
||||
|
||||
public function index(Request $request, int $pressRelease): AnonymousResourceCollection
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||||
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
return PressReleaseImageResource::collection(
|
||||
$pressRelease->images()->orderBy('sort_order')->orderBy('id')->get()
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StorePressReleaseImageRequest $request, int $pressRelease): JsonResponse
|
||||
{
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
if (! $this->canChangeImages($pressRelease)) {
|
||||
return response()->json([
|
||||
'message' => 'Only draft or rejected press releases may be edited.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
/** @var UploadedFile $file */
|
||||
$file = $request->file('image');
|
||||
|
||||
try {
|
||||
$stored = $this->imageService->storePressReleaseImage($file, $pressRelease->id);
|
||||
} catch (RuntimeException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ((bool) ($validated['is_preview'] ?? false)) {
|
||||
$pressRelease->images()->update(['is_preview' => false]);
|
||||
}
|
||||
|
||||
$image = $pressRelease->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => $stored['path'],
|
||||
'variants' => $stored['variants'],
|
||||
'title' => $validated['title'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'copyright' => $validated['copyright'] ?? null,
|
||||
'is_preview' => (bool) ($validated['is_preview'] ?? false),
|
||||
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
|
||||
'width' => $stored['width'],
|
||||
'height' => $stored['height'],
|
||||
'mime' => $stored['mime'],
|
||||
]);
|
||||
|
||||
return PressReleaseImageResource::make($image)
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $pressReleaseImage): Response|JsonResponse
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-release-images:write'), 403);
|
||||
|
||||
$image = PressReleaseImage::query()
|
||||
->whereKey($pressReleaseImage)
|
||||
->whereHas('pressRelease', fn ($query) => $query->withoutGlobalScopes()->where('user_id', $request->user()->id))
|
||||
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScopes()])
|
||||
->first();
|
||||
|
||||
abort_unless($image !== null, 403);
|
||||
|
||||
if (! $this->canChangeImages($image->pressRelease)) {
|
||||
return response()->json([
|
||||
'message' => 'Only draft or rejected press releases may be edited.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
$image->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->whereKey($pressReleaseId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function canChangeImages(PressRelease $pressRelease): bool
|
||||
{
|
||||
$status = $pressRelease->status instanceof PressReleaseStatus
|
||||
? $pressRelease->status->value
|
||||
: (string) $pressRelease->status;
|
||||
|
||||
return in_array($status, [PressReleaseStatus::Draft->value, PressReleaseStatus::Rejected->value], true);
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/Auth/MagicLinkConsumeController.php
Normal file
52
app/Http/Controllers/Auth/MagicLinkConsumeController.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MagicLink;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class MagicLinkConsumeController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $token): RedirectResponse
|
||||
{
|
||||
$magicLink = MagicLink::query()
|
||||
->with('user')
|
||||
->where('token_hash', hash('sha256', $token))
|
||||
->where('purpose', 'login')
|
||||
->first();
|
||||
|
||||
if (! $magicLink || ! $magicLink->user) {
|
||||
return redirect()->route('login')->with('status', __('The magic login link is invalid.'));
|
||||
}
|
||||
|
||||
if ($magicLink->consumed_at !== null || $magicLink->expires_at->isPast()) {
|
||||
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
|
||||
}
|
||||
|
||||
if (! $magicLink->user->is_active) {
|
||||
return redirect()->route('login')->with('status', __('Your account is not active.'));
|
||||
}
|
||||
|
||||
$magicLink->update([
|
||||
'consumed_at' => now(),
|
||||
'ip_consumed' => $request->ip(),
|
||||
]);
|
||||
|
||||
$magicLink->user->update([
|
||||
'last_login_at' => now(),
|
||||
'last_login_ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
Auth::guard('web')->login($magicLink->user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
$home = $magicLink->user->canAccessAdmin()
|
||||
? route('dashboard', absolute: false)
|
||||
: route('me.dashboard', absolute: false);
|
||||
|
||||
return redirect()->intended($home);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
|
|
@ -14,17 +15,21 @@ class VerifyEmailController extends Controller
|
|||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
$home = $request->user()->canAccessAdmin()
|
||||
? route('dashboard', absolute: false)
|
||||
: route('me.dashboard', absolute: false);
|
||||
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
return redirect()->intended($home.'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
|
||||
/** @var MustVerifyEmail $user */
|
||||
$user = $request->user();
|
||||
|
||||
event(new Verified($user));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
return redirect()->intended($home.'?verified=1');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
app/Http/Controllers/LegacyInvoicePdfController.php
Normal file
32
app/Http/Controllers/LegacyInvoicePdfController.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Services\Billing\LegacyInvoicePdfRenderer;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LegacyInvoicePdfController extends Controller
|
||||
{
|
||||
public function __invoke(LegacyInvoice $legacyInvoice, LegacyInvoicePdfRenderer $renderer): Response
|
||||
{
|
||||
Gate::authorize('downloadPdf', $legacyInvoice);
|
||||
|
||||
if (filled($legacyInvoice->pdf_path) && Storage::disk('local')->exists($legacyInvoice->pdf_path)) {
|
||||
return response()->file(Storage::disk('local')->path($legacyInvoice->pdf_path), [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$renderer->filename($legacyInvoice).'"',
|
||||
'Cache-Control' => 'private, max-age=0, must-revalidate',
|
||||
]);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('legacy_invoices', 'pdf_generated_at')) {
|
||||
$legacyInvoice->forceFill(['pdf_generated_at' => now()])->save();
|
||||
}
|
||||
|
||||
return $renderer->inlineResponse($legacyInvoice);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/PressReleasePreviewController.php
Normal file
51
app/Http/Controllers/PressReleasePreviewController.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\MagicLink;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PressReleasePreviewController extends Controller
|
||||
{
|
||||
public function __invoke(string $token): View|Response
|
||||
{
|
||||
$magicLink = MagicLink::query()
|
||||
->where('token_hash', hash('sha256', $token))
|
||||
->where('purpose', 'press_release_access')
|
||||
->first();
|
||||
|
||||
if (! $magicLink) {
|
||||
return $this->renderError(__('Der Vorschau-Link ist ungültig.'), 404);
|
||||
}
|
||||
|
||||
if ($magicLink->expires_at && $magicLink->expires_at->isPast()) {
|
||||
return $this->renderError(__('Der Vorschau-Link ist abgelaufen.'), 410);
|
||||
}
|
||||
|
||||
$pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
|
||||
|
||||
$pressRelease = $pressReleaseId
|
||||
? PressRelease::withoutGlobalScopes()
|
||||
->with(['company:id,name,slug', 'category.translations', 'images', 'user:id,name'])
|
||||
->find($pressReleaseId)
|
||||
: null;
|
||||
|
||||
if (! $pressRelease) {
|
||||
return $this->renderError(__('Die Pressemitteilung wurde nicht gefunden.'), 404);
|
||||
}
|
||||
|
||||
return view('press-release-preview', [
|
||||
'pressRelease' => $pressRelease,
|
||||
'expiresAt' => $magicLink->expires_at,
|
||||
]);
|
||||
}
|
||||
|
||||
private function renderError(string $message, int $status): Response
|
||||
{
|
||||
return response()->view('press-release-preview-error', [
|
||||
'message' => $message,
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
51
app/Http/Middleware/BasicAuthMiddleware.php
Normal file
51
app/Http/Middleware/BasicAuthMiddleware.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class BasicAuthMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Skip Basic Auth für Livewire-Requests komplett
|
||||
// Diese sind bereits durch Laravel Session/CSRF geschützt
|
||||
$path = $request->path();
|
||||
|
||||
if (
|
||||
str_starts_with($path, 'livewire/') ||
|
||||
str_contains($path, '/livewire/') ||
|
||||
$request->is('livewire/*') ||
|
||||
$request->is('*/livewire/*')
|
||||
) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
|
||||
if (str_starts_with($path, 'flux/')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für API und Short-Links; API-Zugriff wird per Sanctum geschützt.
|
||||
if ($request->is('api/*') || $request->is('_cabinet/*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Credentials from .env file
|
||||
$user = config('auth.basic.user');
|
||||
$pass = config('auth.basic.password');
|
||||
|
||||
if ($request->getUser() != $user || $request->getPassword() != $pass) {
|
||||
return response('Unauthorized.', 401, ['WWW-Authenticate' => 'Basic']);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
71
app/Http/Middleware/EnsureApiTokenRateLimit.php
Normal file
71
app/Http/Middleware/EnsureApiTokenRateLimit.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureApiTokenRateLimit
|
||||
{
|
||||
private const MAX_ATTEMPTS = 60;
|
||||
|
||||
private const DECAY_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$key = $this->rateLimitKey($request);
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, self::MAX_ATTEMPTS)) {
|
||||
$retryAfter = RateLimiter::availableIn($key);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'API rate limit exceeded.',
|
||||
], 429, [
|
||||
'Retry-After' => (string) $retryAfter,
|
||||
'X-RateLimit-Limit' => (string) self::MAX_ATTEMPTS,
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, self::DECAY_SECONDS);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
$response->headers->set('X-RateLimit-Limit', (string) self::MAX_ATTEMPTS);
|
||||
$response->headers->set('X-RateLimit-Remaining', (string) RateLimiter::remaining($key, self::MAX_ATTEMPTS));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function rateLimitKey(Request $request): string
|
||||
{
|
||||
$bearerToken = $request->bearerToken();
|
||||
|
||||
if ($bearerToken !== null && str_contains($bearerToken, '|')) {
|
||||
[$tokenId] = explode('|', $bearerToken, 2);
|
||||
|
||||
if (ctype_digit($tokenId)) {
|
||||
return 'api-v1:token:'.$tokenId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($bearerToken !== null) {
|
||||
return 'api-v1:bearer:'.hash('sha256', $bearerToken);
|
||||
}
|
||||
|
||||
$token = $request->user()?->currentAccessToken();
|
||||
|
||||
if (is_object($token) && method_exists($token, 'getKey') && $token->getKey() !== null) {
|
||||
return 'api-v1:token:'.$token->getKey();
|
||||
}
|
||||
|
||||
return 'api-v1:user:'.($request->user()?->getAuthIdentifier() ?? $request->ip());
|
||||
}
|
||||
}
|
||||
26
app/Http/Middleware/EnsureApiUserIsActive.php
Normal file
26
app/Http/Middleware/EnsureApiUserIsActive.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureApiUserIsActive
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->is_active) {
|
||||
return response()->json([
|
||||
'message' => 'API access is disabled for inactive users.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
34
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
34
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user !== null && $user->is_active && ! $user->is_super_admin) {
|
||||
$user->loadMissing('roles');
|
||||
}
|
||||
|
||||
if (app(UserImpersonation::class)->isActive()) {
|
||||
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
||||
return redirect()->route('me.dashboard');
|
||||
}
|
||||
|
||||
abort(403, 'Während der Benutzer-Impersonation ist der Admin-Bereich gesperrt.');
|
||||
}
|
||||
|
||||
if (! $user?->canAccessAdmin()) {
|
||||
abort(403, 'Kein Zugriff auf den Admin-Bereich.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureUserIsCustomer.php
Normal file
19
app/Http/Middleware/EnsureUserIsCustomer.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsCustomer
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->canAccessCustomer()) {
|
||||
abort(403, 'Kein Zugriff auf das Kundenportal.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
48
app/Http/Middleware/LogApiUsage.php
Normal file
48
app/Http/Middleware/LogApiUsage.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiUsageLog;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LogApiUsage
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$response = $next($request);
|
||||
|
||||
$this->writeLog($request, $response, $startedAt);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function writeLog(Request $request, Response $response, float $startedAt): void
|
||||
{
|
||||
$token = $request->user()?->currentAccessToken();
|
||||
$tokenId = $token instanceof PersonalAccessToken && (int) $token->getKey() > 0
|
||||
? (int) $token->getKey()
|
||||
: null;
|
||||
|
||||
ApiUsageLog::query()->create([
|
||||
'user_id' => $request->user()?->id,
|
||||
'personal_access_token_id' => $tokenId,
|
||||
'method' => $request->method(),
|
||||
'path' => '/'.$request->path(),
|
||||
'route_name' => $request->route()?->getName(),
|
||||
'status_code' => $response->getStatusCode(),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
|
||||
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
|
||||
'requested_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
88
app/Http/Middleware/LogSlowAdminRequests.php
Normal file
88
app/Http/Middleware/LogSlowAdminRequests.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Admin\AdminRequestPerformanceMetrics;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LogSlowAdminRequests
|
||||
{
|
||||
public function __construct(private AdminRequestPerformanceMetrics $metrics) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! config('admin_performance.slow_requests.enabled', true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$startedAt = microtime(true);
|
||||
$this->metrics->start();
|
||||
|
||||
try {
|
||||
$response = $next($request);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->metrics->stop();
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$snapshot = $this->metrics->snapshot();
|
||||
$this->metrics->stop();
|
||||
|
||||
$durationMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||||
|
||||
if ($this->shouldLog($durationMs, $snapshot['database_time_ms'], $snapshot['query_count'])) {
|
||||
$this->logger()->warning('Slow admin request detected.', [
|
||||
'method' => $request->method(),
|
||||
'path' => '/'.$request->path(),
|
||||
'route_name' => $request->route()?->getName(),
|
||||
'status_code' => $response->getStatusCode(),
|
||||
'user_id' => $request->user()?->id,
|
||||
'duration_ms' => $durationMs,
|
||||
'database_time_ms' => $snapshot['database_time_ms'],
|
||||
'query_count' => $snapshot['query_count'],
|
||||
'slow_queries' => $snapshot['slow_queries'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function shouldLog(int $durationMs, float $databaseTimeMs, int $queryCount): bool
|
||||
{
|
||||
return $durationMs >= $this->durationThresholdMs()
|
||||
|| $databaseTimeMs >= $this->databaseThresholdMs()
|
||||
|| $queryCount >= $this->queryCountThreshold();
|
||||
}
|
||||
|
||||
private function logger(): LoggerInterface
|
||||
{
|
||||
$channel = config('admin_performance.slow_requests.channel') ?: config('logging.default');
|
||||
|
||||
return Log::channel(is_string($channel) && $channel !== '' ? $channel : 'stack');
|
||||
}
|
||||
|
||||
private function durationThresholdMs(): int
|
||||
{
|
||||
return (int) config('admin_performance.slow_requests.duration_threshold_ms', 750);
|
||||
}
|
||||
|
||||
private function databaseThresholdMs(): int
|
||||
{
|
||||
return (int) config('admin_performance.slow_requests.database_threshold_ms', 250);
|
||||
}
|
||||
|
||||
private function queryCountThreshold(): int
|
||||
{
|
||||
return (int) config('admin_performance.slow_requests.query_count_threshold', 100);
|
||||
}
|
||||
}
|
||||
28
app/Http/Middleware/RejectLegacyApiKeys.php
Normal file
28
app/Http/Middleware/RejectLegacyApiKeys.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RejectLegacyApiKeys
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->has('api_key') || filled($request->header('X-Api-Key'))) {
|
||||
return response()->json([
|
||||
'message' => 'Legacy API keys are no longer supported.',
|
||||
'migration_url' => url('/customer/tokens'),
|
||||
'docs_url' => url('/docs/api/v1'),
|
||||
], 410);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
56
app/Http/Middleware/SetCurrentPortal.php
Normal file
56
app/Http/Middleware/SetCurrentPortal.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Services\CurrentPortalContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Setzt den Portal-Kontext für den aktuellen Request.
|
||||
*
|
||||
* Reihenfolge der Auflösung:
|
||||
* 1. Admin-Session-Override: Ein angemeldeter Admin kann über die Session ein
|
||||
* bestimmtes Portal forcieren (für die Filteransicht im Admin-Bereich).
|
||||
* 2. Domain-Konfiguration: Das aktive Theme (gesetzt vom ThemeServiceProvider)
|
||||
* bestimmt das Portal über den config('app.theme')-Wert.
|
||||
* 3. Kein Kontext: Portal-Scope filtert nicht (z.B. CLI, Tests).
|
||||
*
|
||||
* Theme → Portal-Mapping:
|
||||
* 'presseecho' → Portal::Presseecho
|
||||
* 'businessportal24'→ Portal::Businessportal24
|
||||
* 'main' / andere → null (Admin-Domain; Super-Admin sieht alles)
|
||||
*/
|
||||
class SetCurrentPortal
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$portal = $this->resolvePortal($request);
|
||||
CurrentPortalContext::set($portal);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function resolvePortal(Request $request): ?Portal
|
||||
{
|
||||
// Admin-Session-Override hat höchste Priorität
|
||||
if ($request->hasSession() && $request->session()->has('admin_portal_filter')) {
|
||||
$overrideValue = $request->session()->get('admin_portal_filter');
|
||||
$override = Portal::tryFrom((string) $overrideValue);
|
||||
if ($override !== null) {
|
||||
return $override;
|
||||
}
|
||||
}
|
||||
|
||||
// Domain-basierte Auflösung via ThemeServiceProvider
|
||||
$theme = config('app.theme', 'main');
|
||||
|
||||
return match ($theme) {
|
||||
'presseecho' => Portal::Presseecho,
|
||||
'businessportal24' => Portal::Businessportal24,
|
||||
default => null, // Admin/Portal-Domain → kein automatischer Filter
|
||||
};
|
||||
}
|
||||
}
|
||||
59
app/Http/Middleware/SetDomainUrl.php
Normal file
59
app/Http/Middleware/SetDomainUrl.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Middleware um die URL-Konfiguration basierend auf der aktuellen Domain zu setzen.
|
||||
*
|
||||
* Diese Middleware muss sehr früh im Request-Lifecycle ausgeführt werden,
|
||||
* um sicherzustellen, dass url() und asset() die richtige Domain verwenden.
|
||||
*/
|
||||
class SetDomainUrl
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$host = $request->getHost();
|
||||
|
||||
// Suche nach der Domain-Konfiguration
|
||||
$domainConfig = null;
|
||||
$domains = config('domains.domains', []);
|
||||
|
||||
foreach ($domains as $name => $config) {
|
||||
if (is_array($config) && isset($config['domain_name']) && $config['domain_name'] === $host) {
|
||||
$domainConfig = $config;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn eine Domain-Konfiguration gefunden wurde, setze die URL
|
||||
if ($domainConfig && isset($domainConfig['url'])) {
|
||||
$domainUrl = $domainConfig['url'];
|
||||
|
||||
// URL-Generator konfigurieren
|
||||
URL::forceRootUrl($domainUrl);
|
||||
URL::forceScheme(parse_url($domainUrl, PHP_URL_SCHEME) ?: 'https');
|
||||
|
||||
// Asset-Root setzen
|
||||
/** @var UrlGenerator $urlGenerator */
|
||||
$urlGenerator = app('url');
|
||||
$urlGenerator->useAssetOrigin($domainUrl);
|
||||
|
||||
// Config aktualisieren
|
||||
config([
|
||||
'app.url' => $domainUrl,
|
||||
'app.asset_url' => $domainUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
57
app/Http/Middleware/ThemeMiddleware.php
Normal file
57
app/Http/Middleware/ThemeMiddleware.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ThemeMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$host = $request->getHost();
|
||||
$path = $request->path();
|
||||
|
||||
// Theme-Switching über Subdomains
|
||||
if (str_contains($host, 'b2in')) {
|
||||
config(['app.theme' => 'b2in']);
|
||||
} elseif (str_contains($host, 'b2a') || str_contains($host, 'bridges2america')) {
|
||||
config(['app.theme' => 'b2a']);
|
||||
} elseif (str_contains($host, 'stileigentum')) {
|
||||
config(['app.theme' => 'stileigentum']);
|
||||
} elseif (str_contains($host, 'style2own')) {
|
||||
config(['app.theme' => 'style2own']);
|
||||
}
|
||||
|
||||
// Theme-Switching über URL-Parameter (für Testing)
|
||||
if ($request->has('theme')) {
|
||||
$theme = $request->get('theme');
|
||||
if (in_array($theme, ['b2in', 'b2a', 'stileigentum', 'style2own'])) {
|
||||
config(['app.theme' => $theme]);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme-Switching über Pfade (für lokale Entwicklung ohne Domain-Setup)
|
||||
if (str_starts_with($path, 'b2in/')) {
|
||||
config(['app.theme' => 'b2in']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 5)); // Entferne 'b2in/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'b2a/') || str_starts_with($path, 'bridges2america/')) {
|
||||
config(['app.theme' => 'b2a']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 4)); // Entferne 'b2a/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'stileigentum/')) {
|
||||
config(['app.theme' => 'stileigentum']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 13)); // Entferne 'stileigentum/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'style2own/')) {
|
||||
config(['app.theme' => 'style2own']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 10)); // Entferne 'style2own/' vom Pfad
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
33
app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php
Normal file
33
app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StorePressReleaseImageRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->tokenCan('press-release-images:write') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:8192'],
|
||||
'title' => ['nullable', 'string', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'copyright' => ['nullable', 'string', 'max:255'],
|
||||
'is_preview' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/Api/V1/StorePressReleaseRequest.php
Normal file
44
app/Http/Requests/Api/V1/StorePressReleaseRequest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StorePressReleaseRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->tokenCan('press-releases:write') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'company_id' => ['required', 'integer', 'exists:companies,id'],
|
||||
'category_id' => ['required', 'integer', 'exists:categories,id'],
|
||||
'language' => ['required', 'string', 'size:2'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'text' => ['required', 'string'],
|
||||
'backlink_url' => ['nullable', 'url', 'max:255'],
|
||||
'keywords' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', Rule::in([
|
||||
PressReleaseStatus::Draft->value,
|
||||
PressReleaseStatus::Review->value,
|
||||
])],
|
||||
'teaser_begin' => ['nullable', 'integer', 'min:0'],
|
||||
'teaser_end' => ['nullable', 'integer', 'min:0'],
|
||||
'no_export' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Http/Requests/Api/V1/SubscribeNewsletterRequest.php
Normal file
38
app/Http/Requests/Api/V1/SubscribeNewsletterRequest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SubscribeNewsletterRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->tokenCan('newsletter:subscribe') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'portal' => ['required', Rule::in([
|
||||
Portal::Presseecho->value,
|
||||
Portal::Businessportal24->value,
|
||||
])],
|
||||
'email' => ['required', 'email', 'max:190'],
|
||||
'salutation_key' => ['nullable', 'string', 'max:20'],
|
||||
'first_name' => ['nullable', 'string', 'max:80'],
|
||||
'last_name' => ['nullable', 'string', 'max:80'],
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php
Normal file
44
app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdatePressReleaseRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->tokenCan('press-releases:write') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'company_id' => ['sometimes', 'required', 'integer', 'exists:companies,id'],
|
||||
'category_id' => ['sometimes', 'required', 'integer', 'exists:categories,id'],
|
||||
'language' => ['sometimes', 'required', 'string', 'size:2'],
|
||||
'title' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'text' => ['sometimes', 'required', 'string'],
|
||||
'backlink_url' => ['nullable', 'url', 'max:255'],
|
||||
'keywords' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['sometimes', Rule::in([
|
||||
PressReleaseStatus::Draft->value,
|
||||
PressReleaseStatus::Review->value,
|
||||
])],
|
||||
'teaser_begin' => ['nullable', 'integer', 'min:0'],
|
||||
'teaser_end' => ['nullable', 'integer', 'min:0'],
|
||||
'no_export' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Resources/CategoryResource.php
Normal file
36
app/Http/Resources/CategoryResource.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CategoryResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'legacy' => [
|
||||
'portal' => $this->legacy_portal,
|
||||
'id' => $this->legacy_id,
|
||||
],
|
||||
'portal' => $this->portal?->value,
|
||||
'is_active' => $this->is_active,
|
||||
'translations' => $this->whenLoaded('translations', fn () => $this->translations
|
||||
->mapWithKeys(fn ($translation) => [
|
||||
$translation->locale => [
|
||||
'name' => $translation->name,
|
||||
'slug' => $translation->slug,
|
||||
'description' => $translation->description,
|
||||
],
|
||||
])
|
||||
->all()),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Resources/CompanyResource.php
Normal file
36
app/Http/Resources/CompanyResource.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CompanyResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'legacy' => [
|
||||
'portal' => $this->legacy_portal,
|
||||
'id' => $this->legacy_id,
|
||||
],
|
||||
'portal' => $this->portal?->value,
|
||||
'type' => $this->type?->value,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'email' => $this->email,
|
||||
'website' => $this->website,
|
||||
'phone' => $this->phone,
|
||||
'country_code' => $this->country_code,
|
||||
'is_active' => $this->is_active,
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Resources/PressReleaseImageResource.php
Normal file
46
app/Http/Resources/PressReleaseImageResource.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PressReleaseImageResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$variants = is_array($this->variants) ? $this->variants : [];
|
||||
$variantUrls = [];
|
||||
|
||||
foreach ($variants as $key => $relativePath) {
|
||||
if (is_string($relativePath) && filled($relativePath)) {
|
||||
$variantUrls[$key] = asset('storage/'.ltrim($relativePath, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'press_release_id' => $this->press_release_id,
|
||||
'url' => $this->url(),
|
||||
'urls' => array_merge(['original' => $this->url()], $variantUrls),
|
||||
'variants' => $variants,
|
||||
'disk' => $this->disk,
|
||||
'path' => $this->path,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'copyright' => $this->copyright,
|
||||
'is_preview' => $this->is_preview,
|
||||
'sort_order' => $this->sort_order,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'mime' => $this->mime,
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Resources/PressReleaseResource.php
Normal file
46
app/Http/Resources/PressReleaseResource.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PressReleaseResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'legacy' => [
|
||||
'portal' => $this->legacy_portal,
|
||||
'id' => $this->legacy_id,
|
||||
],
|
||||
'portal' => $this->portal?->value,
|
||||
'language' => $this->language,
|
||||
'title' => $this->title,
|
||||
'slug' => $this->slug,
|
||||
'text' => $this->text,
|
||||
'backlink_url' => $this->backlink_url,
|
||||
'keywords' => $this->keywords,
|
||||
'status' => $this->status?->value,
|
||||
'hits' => $this->hits,
|
||||
'teaser' => [
|
||||
'begin' => $this->teaser_begin,
|
||||
'end' => $this->teaser_end,
|
||||
],
|
||||
'no_export' => $this->no_export,
|
||||
'published_at' => $this->published_at?->toIso8601String(),
|
||||
'company' => CompanyResource::make($this->whenLoaded('company')),
|
||||
'category' => CategoryResource::make($this->whenLoaded('category')),
|
||||
'images' => PressReleaseImageResource::collection($this->whenLoaded('images')),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Responses/RoleAwareLoginResponse.php
Normal file
51
app/Http/Responses/RoleAwareLoginResponse.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
|
||||
/**
|
||||
* Leitet Panel-User nach erfolgreichem Login je nach Rolle:
|
||||
* - Admin/Editor → /dashboard (Admin-Bereich)
|
||||
* - Customer → /admin/me (Mein Bereich)
|
||||
*/
|
||||
class RoleAwareLoginResponse implements LoginResponseContract
|
||||
{
|
||||
public function toResponse($request): RedirectResponse|JsonResponse
|
||||
{
|
||||
if ($request instanceof Request && $request->wantsJson()) {
|
||||
return new JsonResponse('', 204);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$intended = redirect()->intended();
|
||||
|
||||
if ($user?->canAccessAdmin()) {
|
||||
return $intended->setTargetUrl(
|
||||
$this->resolveTarget($intended->getTargetUrl(), route('dashboard'))
|
||||
);
|
||||
}
|
||||
|
||||
if ($user?->canAccessCustomer()) {
|
||||
return $intended->setTargetUrl(
|
||||
$this->resolveTarget($intended->getTargetUrl(), route('me.dashboard'))
|
||||
);
|
||||
}
|
||||
|
||||
return $intended->setTargetUrl(url('/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt die Intended-URL nur, wenn sie nicht auf den Default-Home-Pfad zeigt.
|
||||
*/
|
||||
private function resolveTarget(string $intendedUrl, string $fallback): string
|
||||
{
|
||||
$homePath = (string) config('fortify.home', '/dashboard');
|
||||
$intendedPath = parse_url($intendedUrl, PHP_URL_PATH) ?: '/';
|
||||
|
||||
return $intendedPath === $homePath || $intendedPath === '/' ? $fallback : $intendedUrl;
|
||||
}
|
||||
}
|
||||
35
app/Mail/GoLivePasswordReset.php
Normal file
35
app/Mail/GoLivePasswordReset.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class GoLivePasswordReset extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $user,
|
||||
public readonly string $resetUrl,
|
||||
public readonly int $expiresInMinutes = 60,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Bitte Passwort setzen – '.config('app.name'),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.auth.go-live-password-reset',
|
||||
);
|
||||
}
|
||||
}
|
||||
40
app/Mail/MagicLoginLink.php
Normal file
40
app/Mail/MagicLoginLink.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MagicLoginLink extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public string $loginUrl,
|
||||
public string $expiresAt
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Ihr Login-Link fuer presseportale'
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.auth.magic-login-link'
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
39
app/Mail/PressReleasePublished.php
Normal file
39
app/Mail/PressReleasePublished.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PressReleasePublished extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public string $showUrl;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $user,
|
||||
public readonly PressRelease $pressRelease,
|
||||
) {
|
||||
$this->showUrl = $user->canAccessAdmin()
|
||||
? route('admin.press-releases.show', $pressRelease->id)
|
||||
: route('me.press-releases.show', $pressRelease->id);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Ihre Pressemitteilung wurde veröffentlicht: '.$this->pressRelease->title,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.press-release.published');
|
||||
}
|
||||
}
|
||||
40
app/Mail/PressReleaseRejected.php
Normal file
40
app/Mail/PressReleaseRejected.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PressReleaseRejected extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public string $editUrl;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $user,
|
||||
public readonly PressRelease $pressRelease,
|
||||
public readonly ?string $reason = null,
|
||||
) {
|
||||
$this->editUrl = $user->canAccessAdmin()
|
||||
? route('admin.press-releases.edit', $pressRelease->id)
|
||||
: route('me.press-releases.edit', $pressRelease->id);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Ihre Pressemitteilung wurde abgelehnt: '.$this->pressRelease->title,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.press-release.rejected');
|
||||
}
|
||||
}
|
||||
41
app/Models/AdminPreset.php
Normal file
41
app/Models/AdminPreset.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\AdminPresetFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AdminPreset extends Model
|
||||
{
|
||||
/** @use HasFactory<AdminPresetFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const PRESS_RELEASE_DELETED_PUBLISHED_TEXT = 'press_releases.deleted_published_text';
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'area',
|
||||
'type',
|
||||
'label',
|
||||
'value',
|
||||
'payload',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public static function activeValue(string $key, ?string $fallback = null): ?string
|
||||
{
|
||||
return static::query()
|
||||
->where('key', $key)
|
||||
->where('is_active', true)
|
||||
->value('value') ?? $fallback;
|
||||
}
|
||||
}
|
||||
42
app/Models/ApiUsageLog.php
Normal file
42
app/Models/ApiUsageLog.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class ApiUsageLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'personal_access_token_id',
|
||||
'method',
|
||||
'path',
|
||||
'route_name',
|
||||
'status_code',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'duration_ms',
|
||||
'requested_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'requested_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function personalAccessToken(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PersonalAccessToken::class);
|
||||
}
|
||||
}
|
||||
29
app/Models/BillingAddress.php
Normal file
29
app/Models/BillingAddress.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BillingAddress extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'salutation_key',
|
||||
'title',
|
||||
'name',
|
||||
'address1',
|
||||
'address2',
|
||||
'postal_code',
|
||||
'city',
|
||||
'country_code',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
58
app/Models/Category.php
Normal file
58
app/Models/Category.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Database\Factories\CategoryFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
/** @use HasFactory<CategoryFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'parent_id',
|
||||
'portal',
|
||||
'is_active',
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'portal' => Portal::class,
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function translations(): HasMany
|
||||
{
|
||||
return $this->hasMany(CategoryTranslation::class);
|
||||
}
|
||||
|
||||
public function pressReleases(): HasMany
|
||||
{
|
||||
return $this->hasMany(PressRelease::class);
|
||||
}
|
||||
|
||||
public function footerCodes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(FooterCode::class, 'category_footer_code');
|
||||
}
|
||||
}
|
||||
51
app/Models/CategoryTranslation.php
Normal file
51
app/Models/CategoryTranslation.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CategoryTranslation extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'locale',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a slug that is unique within a single locale.
|
||||
* If `$ignoreCategoryId` is given (when editing), a clash with this very
|
||||
* category's translation is allowed, so renaming back to the same slug
|
||||
* works.
|
||||
*/
|
||||
public static function uniqueSlug(string $source, string $locale, ?int $ignoreCategoryId = null): string
|
||||
{
|
||||
$base = Str::slug($source) ?: 'kategorie';
|
||||
$slug = $base;
|
||||
$i = 2;
|
||||
|
||||
while (
|
||||
self::query()
|
||||
->where('locale', $locale)
|
||||
->where('slug', $slug)
|
||||
->when(
|
||||
$ignoreCategoryId !== null,
|
||||
fn ($q) => $q->where('category_id', '!=', $ignoreCategoryId),
|
||||
)
|
||||
->exists()
|
||||
) {
|
||||
$slug = $base.'-'.$i++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
137
app/Models/Company.php
Normal file
137
app/Models/Company.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CompanyType;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Concerns\HasUniqueSlug;
|
||||
use App\Scopes\PortalScope;
|
||||
use Database\Factories\CompanyFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Company extends Model
|
||||
{
|
||||
/** @use HasFactory<CompanyFactory> */
|
||||
use HasFactory, HasUniqueSlug, SoftDeletes;
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function slugScopeAttributes(): array
|
||||
{
|
||||
return ['portal'];
|
||||
}
|
||||
|
||||
protected function slugFallback(): string
|
||||
{
|
||||
return 'company';
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope(new PortalScope);
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'portal',
|
||||
'owner_user_id',
|
||||
'type',
|
||||
'name',
|
||||
'slug',
|
||||
'address',
|
||||
'country_code',
|
||||
'phone',
|
||||
'fax',
|
||||
'email',
|
||||
'website',
|
||||
'logo_path',
|
||||
'logo_variants',
|
||||
'is_active',
|
||||
'disable_footer_code',
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'portal' => Portal::class,
|
||||
'type' => CompanyType::class,
|
||||
'logo_variants' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'disable_footer_code' => 'boolean',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class)
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function contacts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Contact::class);
|
||||
}
|
||||
|
||||
public function pressReleases(): HasMany
|
||||
{
|
||||
return $this->hasMany(PressRelease::class);
|
||||
}
|
||||
|
||||
public function userPaymentOptions(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(UserPaymentOption::class, 'user_payment_option_company')
|
||||
->withPivot('is_active')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function logoUrl(): ?string
|
||||
{
|
||||
if (blank($this->logo_path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$logoPath = trim((string) $this->logo_path);
|
||||
$legacyPortal = $this->legacy_portal ?: $this->portal?->value;
|
||||
$logoFilename = basename((string) (parse_url($logoPath, PHP_URL_PATH) ?: $logoPath));
|
||||
$candidates = [];
|
||||
|
||||
if (Str::startsWith($logoPath, '/storage/')) {
|
||||
$candidates[] = Str::after($logoPath, '/storage/');
|
||||
} elseif (! Str::startsWith($logoPath, ['http://', 'https://'])) {
|
||||
$candidates[] = ltrim($logoPath, '/');
|
||||
}
|
||||
|
||||
if ($legacyPortal && filled($logoFilename)) {
|
||||
$candidates[] = "company-logos/{$legacyPortal}/{$this->id}/{$logoFilename}";
|
||||
$candidates[] = "{$legacyPortal}/company/{$logoFilename}";
|
||||
}
|
||||
|
||||
foreach (array_unique($candidates) as $candidate) {
|
||||
if (Storage::disk('public')->exists($candidate)) {
|
||||
return asset('storage/'.$candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (Str::startsWith($logoPath, ['http://', 'https://']) && blank($this->legacy_portal)) {
|
||||
return $logoPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
109
app/Models/Concerns/HasUniqueSlug.php
Normal file
109
app/Models/Concerns/HasUniqueSlug.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Generates a unique slug for an Eloquent model based on a source attribute.
|
||||
*
|
||||
* Models opt-in by overriding two methods:
|
||||
* - {@see slugScopeAttributes()} returns the attribute names whose current
|
||||
* model values (or provided overrides) should restrict slug uniqueness.
|
||||
* Defaults to an empty array (global uniqueness on the slug column).
|
||||
* - {@see slugFallback()} returns the fallback when the source attribute is
|
||||
* blank (defaults to "item").
|
||||
*
|
||||
* The model is expected to expose `slug_column` (defaults to `slug`) and
|
||||
* the source attribute name to slugify (defaults to `title` or `name`).
|
||||
*
|
||||
* Typical usage:
|
||||
*
|
||||
* $pr->slug = $pr->generateUniqueSlug($pr->title, [
|
||||
* 'portal' => $pr->portal,
|
||||
* 'language' => $pr->language,
|
||||
* ]);
|
||||
*
|
||||
* @mixin Model
|
||||
*/
|
||||
trait HasUniqueSlug
|
||||
{
|
||||
/**
|
||||
* Build a unique slug from `$source`. Optionally pass overrides for the
|
||||
* scoping attributes (matched against {@see slugScopeAttributes()}).
|
||||
*
|
||||
* @param array<string, mixed> $scope
|
||||
*/
|
||||
public function generateUniqueSlug(string $source, array $scope = []): string
|
||||
{
|
||||
$base = Str::slug($source) ?: $this->slugFallback();
|
||||
$slug = $base;
|
||||
$suffix = 2;
|
||||
|
||||
while ($this->slugExists($slug, $scope)) {
|
||||
$slug = $base.'-'.$suffix++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scope
|
||||
*/
|
||||
protected function slugExists(string $slug, array $scope): bool
|
||||
{
|
||||
/** @var Model $self */
|
||||
$self = $this;
|
||||
|
||||
$query = $self::query()
|
||||
->withoutGlobalScopes()
|
||||
->where($this->slugColumn(), $slug);
|
||||
|
||||
if ($self->exists) {
|
||||
$query->where($self->getKeyName(), '!=', $self->getKey());
|
||||
}
|
||||
|
||||
foreach ($this->slugScopeAttributes() as $attribute) {
|
||||
$value = array_key_exists($attribute, $scope)
|
||||
? $scope[$attribute]
|
||||
: $self->getAttribute($attribute);
|
||||
|
||||
$value !== null
|
||||
? $query->where($attribute, $value)
|
||||
: $query->whereNull($attribute);
|
||||
}
|
||||
|
||||
$this->applySlugConstraints($query);
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function slugScopeAttributes(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function slugColumn(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
protected function slugFallback(): string
|
||||
{
|
||||
return 'item';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for models to add additional WHERE constraints (e.g. soft-deleted
|
||||
* records). Default: no additional constraints.
|
||||
*/
|
||||
protected function applySlugConstraints(Builder $query): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
62
app/Models/Contact.php
Normal file
62
app/Models/Contact.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Scopes\PortalScope;
|
||||
use Database\Factories\ContactFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Contact extends Model
|
||||
{
|
||||
/** @use HasFactory<ContactFactory> */
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope(new PortalScope);
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'portal',
|
||||
'salutation_key',
|
||||
'title',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'responsibility',
|
||||
'phone',
|
||||
'fax',
|
||||
'email',
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'portal' => Portal::class,
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
public function pressReleases(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(PressRelease::class, 'press_release_contact');
|
||||
}
|
||||
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class)
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
43
app/Models/FooterCode.php
Normal file
43
app/Models/FooterCode.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Database\Factories\FooterCodeFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class FooterCode extends Model
|
||||
{
|
||||
/** @use HasFactory<FooterCodeFactory> */
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'portal',
|
||||
'language',
|
||||
'title',
|
||||
'content',
|
||||
'is_global',
|
||||
'is_active',
|
||||
'priority',
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'portal' => Portal::class,
|
||||
'is_global' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'priority' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function categories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Category::class, 'category_footer_code');
|
||||
}
|
||||
}
|
||||
62
app/Models/Invoice.php
Normal file
62
app/Models/Invoice.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\InvoiceStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Invoice extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'user_payment_id',
|
||||
'invoice_billing_address_id',
|
||||
'number',
|
||||
'status',
|
||||
'amount_cents',
|
||||
'tax_cents',
|
||||
'total_cents',
|
||||
'currency',
|
||||
'is_netto',
|
||||
'invoice_date',
|
||||
'due_date',
|
||||
'paid_at',
|
||||
'stripe_invoice_id',
|
||||
'pdf_path',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => InvoiceStatus::class,
|
||||
'amount_cents' => 'integer',
|
||||
'tax_cents' => 'integer',
|
||||
'total_cents' => 'integer',
|
||||
'is_netto' => 'boolean',
|
||||
'invoice_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'paid_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function userPayment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserPayment::class, 'user_payment_id');
|
||||
}
|
||||
|
||||
public function invoiceBillingAddress(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvoiceBillingAddress::class);
|
||||
}
|
||||
}
|
||||
28
app/Models/InvoiceBillingAddress.php
Normal file
28
app/Models/InvoiceBillingAddress.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class InvoiceBillingAddress extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'salutation_key',
|
||||
'title',
|
||||
'name',
|
||||
'address1',
|
||||
'address2',
|
||||
'postal_code',
|
||||
'city',
|
||||
'country_code',
|
||||
];
|
||||
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invoice::class);
|
||||
}
|
||||
}
|
||||
32
app/Models/LegacyImportMap.php
Normal file
32
app/Models/LegacyImportMap.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LegacyImportMap extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'legacy_import_map';
|
||||
|
||||
protected $fillable = [
|
||||
'legacy_portal',
|
||||
'legacy_table',
|
||||
'legacy_id',
|
||||
'target_table',
|
||||
'target_id',
|
||||
'imported_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'legacy_portal' => Portal::class,
|
||||
'legacy_id' => 'integer',
|
||||
'target_id' => 'integer',
|
||||
'imported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
55
app/Models/LegacyInvoice.php
Normal file
55
app/Models/LegacyInvoice.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class LegacyInvoice extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
'user_id',
|
||||
'legacy_user_id',
|
||||
'number',
|
||||
'amount_cents',
|
||||
'tax_cents',
|
||||
'total_cents',
|
||||
'status',
|
||||
'invoice_date',
|
||||
'due_date',
|
||||
'paid_at',
|
||||
'payment_method',
|
||||
'pdf_path',
|
||||
'raw_snapshot',
|
||||
'pdf_payload',
|
||||
'pdf_generated_at',
|
||||
'imported_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'legacy_portal' => Portal::class,
|
||||
'amount_cents' => 'integer',
|
||||
'tax_cents' => 'integer',
|
||||
'total_cents' => 'integer',
|
||||
'invoice_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'paid_at' => 'datetime',
|
||||
'raw_snapshot' => 'array',
|
||||
'pdf_payload' => 'array',
|
||||
'pdf_generated_at' => 'datetime',
|
||||
'imported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
34
app/Models/MagicLink.php
Normal file
34
app/Models/MagicLink.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MagicLink extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'token_hash',
|
||||
'purpose',
|
||||
'payload',
|
||||
'expires_at',
|
||||
'consumed_at',
|
||||
'ip_requested',
|
||||
'ip_consumed',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
'expires_at' => 'datetime',
|
||||
'consumed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
47
app/Models/NewsletterSubscription.php
Normal file
47
app/Models/NewsletterSubscription.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NewsletterSubscription extends Model
|
||||
{
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope(new PortalScope);
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'portal',
|
||||
'user_id',
|
||||
'salutation_key',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'ip_address',
|
||||
'is_confirmed',
|
||||
'confirmation_token',
|
||||
'subscribed_at',
|
||||
'unsubscribed_at',
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'portal' => Portal::class,
|
||||
'is_confirmed' => 'boolean',
|
||||
'subscribed_at' => 'datetime',
|
||||
'unsubscribed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
45
app/Models/PaymentOption.php
Normal file
45
app/Models/PaymentOption.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PaymentOptionType;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PaymentOption extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'article_number',
|
||||
'type',
|
||||
'price_cents',
|
||||
'currency',
|
||||
'interval',
|
||||
'is_hidden',
|
||||
'stripe_product_id',
|
||||
'stripe_price_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => PaymentOptionType::class,
|
||||
'price_cents' => 'integer',
|
||||
'is_hidden' => 'boolean',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function translations(): HasMany
|
||||
{
|
||||
return $this->hasMany(PaymentOptionTranslation::class);
|
||||
}
|
||||
|
||||
public function userPaymentOptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserPaymentOption::class);
|
||||
}
|
||||
}
|
||||
24
app/Models/PaymentOptionTranslation.php
Normal file
24
app/Models/PaymentOptionTranslation.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PaymentOptionTranslation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'payment_option_id',
|
||||
'locale',
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function paymentOption(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PaymentOption::class);
|
||||
}
|
||||
}
|
||||
105
app/Models/PressRelease.php
Normal file
105
app/Models/PressRelease.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Concerns\HasUniqueSlug;
|
||||
use App\Scopes\PortalScope;
|
||||
use Database\Factories\PressReleaseFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PressRelease extends Model
|
||||
{
|
||||
/** @use HasFactory<PressReleaseFactory> */
|
||||
use HasFactory, HasUniqueSlug, SoftDeletes;
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function slugScopeAttributes(): array
|
||||
{
|
||||
return ['portal', 'language'];
|
||||
}
|
||||
|
||||
protected function slugFallback(): string
|
||||
{
|
||||
return 'pressemitteilung';
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope(new PortalScope);
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'portal',
|
||||
'user_id',
|
||||
'company_id',
|
||||
'category_id',
|
||||
'language',
|
||||
'title',
|
||||
'slug',
|
||||
'text',
|
||||
'backlink_url',
|
||||
'keywords',
|
||||
'status',
|
||||
'hits',
|
||||
'teaser_begin',
|
||||
'teaser_end',
|
||||
'no_export',
|
||||
'published_at',
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'portal' => Portal::class,
|
||||
'status' => PressReleaseStatus::class,
|
||||
'hits' => 'integer',
|
||||
'teaser_begin' => 'integer',
|
||||
'teaser_end' => 'integer',
|
||||
'no_export' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function images(): HasMany
|
||||
{
|
||||
return $this->hasMany(PressReleaseImage::class);
|
||||
}
|
||||
|
||||
public function contacts(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Contact::class, 'press_release_contact');
|
||||
}
|
||||
|
||||
public function statusLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at');
|
||||
}
|
||||
}
|
||||
86
app/Models/PressReleaseImage.php
Normal file
86
app/Models/PressReleaseImage.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PressReleaseImage extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'press_release_id',
|
||||
'disk',
|
||||
'path',
|
||||
'variants',
|
||||
'title',
|
||||
'description',
|
||||
'copyright',
|
||||
'is_preview',
|
||||
'sort_order',
|
||||
'width',
|
||||
'height',
|
||||
'mime',
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'variants' => 'array',
|
||||
'is_preview' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function pressRelease(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PressRelease::class);
|
||||
}
|
||||
|
||||
public function url(): ?string
|
||||
{
|
||||
if (blank($this->path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveDiskUrl($this->path);
|
||||
}
|
||||
|
||||
public function variantUrl(string $key): ?string
|
||||
{
|
||||
$variantPath = $this->variants[$key] ?? null;
|
||||
|
||||
if (! is_string($variantPath) || blank($variantPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveDiskUrl($variantPath);
|
||||
}
|
||||
|
||||
private function resolveDiskUrl(string $relativePath): ?string
|
||||
{
|
||||
if ($this->disk === 'public') {
|
||||
return asset('storage/'.ltrim($relativePath, '/'));
|
||||
}
|
||||
|
||||
try {
|
||||
$disk = Storage::disk($this->disk);
|
||||
|
||||
if (method_exists($disk, 'url')) {
|
||||
return $disk->url($relativePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/Models/PressReleaseStatusLog.php
Normal file
41
app/Models/PressReleaseStatusLog.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PressReleaseStatusLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'press_release_id',
|
||||
'changed_by_user_id',
|
||||
'from_status',
|
||||
'to_status',
|
||||
'reason',
|
||||
'source',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
'from_status' => PressReleaseStatus::class,
|
||||
'to_status' => PressReleaseStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function pressRelease(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PressRelease::class);
|
||||
}
|
||||
|
||||
public function changedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'changed_by_user_id');
|
||||
}
|
||||
}
|
||||
52
app/Models/Profile.php
Normal file
52
app/Models/Profile.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ProfileFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Profile extends Model
|
||||
{
|
||||
/** @use HasFactory<ProfileFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'salutation_key',
|
||||
'title',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'phone',
|
||||
'address',
|
||||
'country_code',
|
||||
'birthdate',
|
||||
'backlink_url',
|
||||
'show_stats',
|
||||
'validation_date',
|
||||
'contract_date',
|
||||
'validate_token',
|
||||
'tax_id_number',
|
||||
'tax_exempt',
|
||||
'tax_exempt_reason',
|
||||
'disable_footer_code',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'birthdate' => 'date',
|
||||
'show_stats' => 'boolean',
|
||||
'validation_date' => 'datetime',
|
||||
'contract_date' => 'datetime',
|
||||
'tax_exempt' => 'boolean',
|
||||
'disable_footer_code' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,17 +3,25 @@
|
|||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\RegistrationType;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable, HasRoles, TwoFactorAuthenticatable;
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
@ -23,6 +31,17 @@ class User extends Authenticatable
|
|||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'portal',
|
||||
'registration_type',
|
||||
'language',
|
||||
'is_active',
|
||||
'is_super_admin',
|
||||
'last_login_at',
|
||||
'last_login_ip',
|
||||
'gdpr_consent_at',
|
||||
'last_seen_at',
|
||||
'legacy_portal',
|
||||
'legacy_id',
|
||||
'password',
|
||||
];
|
||||
|
||||
|
|
@ -45,6 +64,14 @@ class User extends Authenticatable
|
|||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'portal' => Portal::class,
|
||||
'registration_type' => RegistrationType::class,
|
||||
'is_active' => 'boolean',
|
||||
'is_super_admin' => 'boolean',
|
||||
'last_login_at' => 'datetime',
|
||||
'gdpr_consent_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
|
@ -59,4 +86,89 @@ class User extends Authenticatable
|
|||
->map(fn (string $name) => Str::of($name)->substr(0, 1))
|
||||
->implode('');
|
||||
}
|
||||
|
||||
public function profile(): HasOne
|
||||
{
|
||||
return $this->hasOne(Profile::class);
|
||||
}
|
||||
|
||||
public function magicLinks(): HasMany
|
||||
{
|
||||
return $this->hasMany(MagicLink::class);
|
||||
}
|
||||
|
||||
public function ownedCompanies(): HasMany
|
||||
{
|
||||
return $this->hasMany(Company::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
public function companies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Company::class)
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function contacts(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Contact::class)
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function pressReleases(): HasMany
|
||||
{
|
||||
return $this->hasMany(PressRelease::class);
|
||||
}
|
||||
|
||||
public function newsletterSubscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(NewsletterSubscription::class);
|
||||
}
|
||||
|
||||
public function billingAddress(): HasOne
|
||||
{
|
||||
return $this->hasOne(BillingAddress::class);
|
||||
}
|
||||
|
||||
public function userPaymentOptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserPaymentOption::class);
|
||||
}
|
||||
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invoice::class);
|
||||
}
|
||||
|
||||
public function legacyInvoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(LegacyInvoice::class);
|
||||
}
|
||||
|
||||
public function filterPresets(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserFilterPreset::class);
|
||||
}
|
||||
|
||||
public function canAccessAdmin(): bool
|
||||
{
|
||||
if (! $this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->is_super_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->hasAnyRole(['admin', 'editor']);
|
||||
}
|
||||
|
||||
public function canAccessCustomer(): bool
|
||||
{
|
||||
if (! $this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->hasAnyRole(['admin', 'editor', 'customer']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
app/Models/UserFilterPreset.php
Normal file
32
app/Models/UserFilterPreset.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserFilterPreset extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'page',
|
||||
'name',
|
||||
'is_default',
|
||||
'last_used_at',
|
||||
'filters',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_default' => 'boolean',
|
||||
'last_used_at' => 'datetime',
|
||||
'filters' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
41
app/Models/UserPayment.php
Normal file
41
app/Models/UserPayment.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PaymentStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class UserPayment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_payment_option_id',
|
||||
'amount_cents',
|
||||
'currency',
|
||||
'status',
|
||||
'stripe_charge_id',
|
||||
'stripe_invoice_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount_cents' => 'integer',
|
||||
'status' => PaymentStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function userPaymentOption(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserPaymentOption::class);
|
||||
}
|
||||
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invoice::class, 'user_payment_id');
|
||||
}
|
||||
}
|
||||
61
app/Models/UserPaymentOption.php
Normal file
61
app/Models/UserPaymentOption.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class UserPaymentOption extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'payment_option_id',
|
||||
'status',
|
||||
'grandfathered_until',
|
||||
'legacy_conditions',
|
||||
'current_period_start',
|
||||
'current_period_end',
|
||||
'stripe_subscription_id',
|
||||
'cancelled_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => UserPaymentOptionStatus::class,
|
||||
'grandfathered_until' => 'date',
|
||||
'legacy_conditions' => 'array',
|
||||
'current_period_start' => 'date',
|
||||
'current_period_end' => 'date',
|
||||
'cancelled_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function paymentOption(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PaymentOption::class);
|
||||
}
|
||||
|
||||
public function companies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Company::class, 'user_payment_option_company')
|
||||
->withPivot('is_active')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserPayment::class);
|
||||
}
|
||||
}
|
||||
39
app/Observers/AdminPerformanceCacheObserver.php
Normal file
39
app/Observers/AdminPerformanceCacheObserver.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AdminPerformanceCacheObserver
|
||||
{
|
||||
public function created(Model $model): void
|
||||
{
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function updated(Model $model): void
|
||||
{
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function deleted(Model $model): void
|
||||
{
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function restored(Model $model): void
|
||||
{
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function forceDeleted(Model $model): void
|
||||
{
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
private function flush(): void
|
||||
{
|
||||
app(AdminPerformanceCache::class)->flush();
|
||||
}
|
||||
}
|
||||
76
app/Policies/CompanyPolicy.php
Normal file
76
app/Policies/CompanyPolicy.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
|
||||
class CompanyPolicy
|
||||
{
|
||||
public function before(User $user): ?bool
|
||||
{
|
||||
return $user->is_super_admin ? true : null;
|
||||
}
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->canAccessCustomer();
|
||||
}
|
||||
|
||||
public function view(User $user, Company $company): bool
|
||||
{
|
||||
if ($user->canAccessAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isLinked($user, $company);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->canAccessAdmin();
|
||||
}
|
||||
|
||||
public function update(User $user, Company $company): bool
|
||||
{
|
||||
if ($user->canAccessAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isOwnerOrResponsible($user, $company);
|
||||
}
|
||||
|
||||
public function delete(User $user, Company $company): bool
|
||||
{
|
||||
return $user->canAccessAdmin();
|
||||
}
|
||||
|
||||
public function restore(User $user, Company $company): bool
|
||||
{
|
||||
return $user->canAccessAdmin();
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Company $company): bool
|
||||
{
|
||||
return $user->is_super_admin === true;
|
||||
}
|
||||
|
||||
private function isLinked(User $user, Company $company): bool
|
||||
{
|
||||
return $user->companies()->withoutGlobalScopes()->whereKey($company->id)->exists()
|
||||
|| $company->owner_user_id === $user->id;
|
||||
}
|
||||
|
||||
private function isOwnerOrResponsible(User $user, Company $company): bool
|
||||
{
|
||||
if ($company->owner_user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->companies()
|
||||
->withoutGlobalScopes()
|
||||
->whereKey($company->id)
|
||||
->wherePivotIn('role', ['owner', 'responsible'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
66
app/Policies/ContactPolicy.php
Normal file
66
app/Policies/ContactPolicy.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
|
||||
class ContactPolicy
|
||||
{
|
||||
public function before(User $user): ?bool
|
||||
{
|
||||
return $user->is_super_admin ? true : null;
|
||||
}
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->canAccessCustomer();
|
||||
}
|
||||
|
||||
public function view(User $user, Contact $contact): bool
|
||||
{
|
||||
if ($user->canAccessAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->sharesCompanyWith($user, $contact);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->canAccessCustomer();
|
||||
}
|
||||
|
||||
public function update(User $user, Contact $contact): bool
|
||||
{
|
||||
if ($user->canAccessAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->sharesCompanyWith($user, $contact);
|
||||
}
|
||||
|
||||
public function delete(User $user, Contact $contact): bool
|
||||
{
|
||||
return $user->canAccessAdmin();
|
||||
}
|
||||
|
||||
public function restore(User $user, Contact $contact): bool
|
||||
{
|
||||
return $user->canAccessAdmin();
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Contact $contact): bool
|
||||
{
|
||||
return $user->is_super_admin === true;
|
||||
}
|
||||
|
||||
private function sharesCompanyWith(User $user, Contact $contact): bool
|
||||
{
|
||||
if (blank($contact->company_id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->companies()->whereKey($contact->company_id)->exists();
|
||||
}
|
||||
}
|
||||
33
app/Policies/LegacyInvoicePolicy.php
Normal file
33
app/Policies/LegacyInvoicePolicy.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Models\User;
|
||||
|
||||
class LegacyInvoicePolicy
|
||||
{
|
||||
public function before(User $user): ?bool
|
||||
{
|
||||
return $user->is_super_admin ? true : null;
|
||||
}
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->canAccessCustomer();
|
||||
}
|
||||
|
||||
public function view(User $user, LegacyInvoice $legacyInvoice): bool
|
||||
{
|
||||
if ($user->canAccessAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $legacyInvoice->user_id === $user->id;
|
||||
}
|
||||
|
||||
public function downloadPdf(User $user, LegacyInvoice $legacyInvoice): bool
|
||||
{
|
||||
return $this->view($user, $legacyInvoice);
|
||||
}
|
||||
}
|
||||
83
app/Policies/PressReleasePolicy.php
Normal file
83
app/Policies/PressReleasePolicy.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
|
||||
class PressReleasePolicy
|
||||
{
|
||||
public function before(User $user): ?bool
|
||||
{
|
||||
return $user->is_super_admin ? true : null;
|
||||
}
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->canAccessCustomer();
|
||||
}
|
||||
|
||||
public function view(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
if ($user->canAccessAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isAuthor($user, $pressRelease);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->canAccessCustomer();
|
||||
}
|
||||
|
||||
public function update(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
if (! $this->isAuthor($user, $pressRelease) && ! $user->canAccessAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(
|
||||
$pressRelease->status,
|
||||
[PressReleaseStatus::Draft, PressReleaseStatus::Rejected, PressReleaseStatus::Review],
|
||||
true,
|
||||
) || $user->canAccessAdmin();
|
||||
}
|
||||
|
||||
public function submitForReview(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
return $this->isAuthor($user, $pressRelease)
|
||||
&& in_array($pressRelease->status, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], true);
|
||||
}
|
||||
|
||||
public function delete(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
if ($user->canAccessAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isAuthor($user, $pressRelease)
|
||||
&& $pressRelease->status !== PressReleaseStatus::Published;
|
||||
}
|
||||
|
||||
public function restore(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
return $user->canAccessAdmin();
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
return $user->is_super_admin === true;
|
||||
}
|
||||
|
||||
public function publish(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
return $user->canAccessAdmin() && $user->can('press-releases:publish');
|
||||
}
|
||||
|
||||
private function isAuthor(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
return $pressRelease->user_id === $user->id;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,29 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Contracts\NewsletterSyncClient;
|
||||
use App\Helpers\ThemeHelper;
|
||||
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||
use App\Http\Middleware\LogSlowAdminRequests;
|
||||
use App\Models\AdminPreset;
|
||||
use App\Models\Category;
|
||||
use App\Models\CategoryTranslation;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\NewsletterSubscription;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Observers\AdminPerformanceCacheObserver;
|
||||
use App\Services\Admin\AdminRequestPerformanceMetrics;
|
||||
use App\Services\Newsletter\NullNewsletterSyncClient;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -12,7 +33,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->bind(NewsletterSyncClient::class, NullNewsletterSyncClient::class);
|
||||
$this->app->singleton(PressReleaseService::class);
|
||||
$this->app->singleton(AdminRequestPerformanceMetrics::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -20,19 +43,31 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// Force HTTPS when running behind a proxy (like Traefik)
|
||||
if ($this->app->environment('local', 'production')) {
|
||||
$scheme = request()->header('X-Forwarded-Proto')
|
||||
?? request()->server('HTTP_X_FORWARDED_PROTO')
|
||||
?? (request()->secure() ? 'https' : 'http');
|
||||
|
||||
if ($scheme === 'https') {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
// Trust proxies for correct request detection
|
||||
$this->app['request']->server->set('HTTPS', 'on');
|
||||
AdminPreset::observe(AdminPerformanceCacheObserver::class);
|
||||
Category::observe(AdminPerformanceCacheObserver::class);
|
||||
CategoryTranslation::observe(AdminPerformanceCacheObserver::class);
|
||||
Company::observe(AdminPerformanceCacheObserver::class);
|
||||
Contact::observe(AdminPerformanceCacheObserver::class);
|
||||
NewsletterSubscription::observe(AdminPerformanceCacheObserver::class);
|
||||
PressRelease::observe(AdminPerformanceCacheObserver::class);
|
||||
User::observe(AdminPerformanceCacheObserver::class);
|
||||
Permission::observe(AdminPerformanceCacheObserver::class);
|
||||
Role::observe(AdminPerformanceCacheObserver::class);
|
||||
|
||||
// Set dynamic asset URL based on current domain/theme
|
||||
// This is needed for Vite to use the correct asset subdomain
|
||||
//config(['app.asset_url' => 'https://assets.businessportal24.test']);
|
||||
DB::listen(fn (QueryExecuted $query) => app(AdminRequestPerformanceMetrics::class)->record($query));
|
||||
|
||||
//$this->setDynamicAssetUrl();
|
||||
Livewire::addPersistentMiddleware([
|
||||
EnsureUserIsAdmin::class,
|
||||
LogSlowAdminRequests::class,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -41,11 +76,11 @@ class AppServiceProvider extends ServiceProvider
|
|||
protected function setDynamicAssetUrl(): void
|
||||
{
|
||||
try {
|
||||
$assetUrl = \App\Helpers\ThemeHelper::getAssetUrl();
|
||||
$assetUrl = ThemeHelper::getAssetUrl();
|
||||
config(['app.asset_url' => $assetUrl]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to default if theme detection fails
|
||||
config(['app.asset_url' => 'https://assets.pr-copilot.test']);
|
||||
config(['app.asset_url' => 'https://assets.presseportale.test']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ use App\Actions\Fortify\CreateNewUser;
|
|||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use App\Http\Responses\RoleAwareLoginResponse;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||
use Laravel\Fortify\Contracts\LoginResponse;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
|
|
@ -22,7 +24,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->singleton(LoginResponse::class, RoleAwareLoginResponse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
|
@ -32,9 +34,9 @@ class ThemeServiceProvider extends ServiceProvider
|
|||
// Standard-Werte für Domain, die nicht in der Konfiguration sind
|
||||
$domainConfig = [
|
||||
'name' => config('app.name'),
|
||||
'theme' => 'portal',
|
||||
'view_prefix' => 'portal',
|
||||
'assets_dir' => 'build/portal',
|
||||
'theme' => 'b2in',
|
||||
'view_prefix' => 'b2in',
|
||||
'assets_dir' => 'build/b2in',
|
||||
'url' => config('app.url'),
|
||||
'domain_name' => config('app.domain_name'),
|
||||
];
|
||||
|
|
@ -55,35 +57,54 @@ class ThemeServiceProvider extends ServiceProvider
|
|||
$domainConfig = array_merge($domainConfig, $confiDomains[$themeOverride]);
|
||||
}
|
||||
|
||||
// Dynamische ASSET_URL basierend auf der aktuellen Domain setzen
|
||||
// Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden
|
||||
$assetUrl = $domainConfig['url'];
|
||||
|
||||
// Grundlegende Konfiguration im Anwendungskontext verfügbar machen
|
||||
config([
|
||||
'app.theme' => $domainConfig['theme'],
|
||||
'app.view_prefix' => $domainConfig['view_prefix'],
|
||||
'app.domain_name' => $domainConfig['domain_name'],
|
||||
'app.url' => $domainConfig['url'],
|
||||
'app.asset_url' => $domainConfig['asset_url'] ?? $domainConfig['url'],
|
||||
'app.asset_url' => $assetUrl, // Dynamische Asset-URL für die aktuelle Domain
|
||||
]);
|
||||
|
||||
// URL-Generator für die aktuelle Domain konfigurieren
|
||||
// Dies ist wichtig, damit asset() und url() die richtige Domain verwenden
|
||||
URL::forceRootUrl($domainConfig['url']);
|
||||
URL::forceScheme(parse_url($domainConfig['url'], PHP_URL_SCHEME) ?: 'https');
|
||||
|
||||
// WICHTIG: Asset-Root direkt im UrlGenerator setzen
|
||||
// Der asset() Helper verwendet einen separaten Asset-Root
|
||||
/** @var UrlGenerator $urlGenerator */
|
||||
$urlGenerator = app('url');
|
||||
$urlGenerator->useAssetOrigin($assetUrl);
|
||||
|
||||
// Spezifischere Daten für die Views verfügbar machen
|
||||
View::share('theme', $domainConfig['theme']);
|
||||
View::share('viewPrefix', $domainConfig['view_prefix']);
|
||||
View::share('domainName', $domainConfig['domain_name']);
|
||||
View::share('domainConfig', $domainConfig);
|
||||
View::share('domainUrl', $domainConfig['url']);
|
||||
View::share('assetUrl', $assetUrl);
|
||||
|
||||
// Vite-Assets-Konfiguration für die aktuelle Domain
|
||||
if (! app()->runningInConsole() && isset($domainConfig['assets_dir'])) {
|
||||
Vite::useBuildDirectory($domainConfig['assets_dir']);
|
||||
if (! app()->runningInConsole()) {
|
||||
if (isset($domainConfig['assets_dir'])) {
|
||||
Vite::useBuildDirectory($domainConfig['assets_dir']);
|
||||
}
|
||||
|
||||
// Setze die Asset-URL für Vite Dev-Server
|
||||
if (isset($domainConfig['asset_url'])) {
|
||||
// Erstelle temporäre Hot-File mit der richtigen Asset-URL
|
||||
$hotFile = public_path('hot');
|
||||
if (file_exists($hotFile)) {
|
||||
file_put_contents($hotFile, $domainConfig['asset_url']);
|
||||
}
|
||||
|
||||
Vite::useHotFile($hotFile);
|
||||
if (app()->environment('local')) {
|
||||
// Entwicklung: Vite Dev Server mit HMR
|
||||
$viteDevServerUrl = env('VITE_DEV_SERVER_URL', 'https://assets.presseportale.test');
|
||||
Vite::useHotFile(public_path('hot'));
|
||||
config(['app.vite_dev_server_url' => $viteDevServerUrl]);
|
||||
View::share('viteDevServerUrl', $viteDevServerUrl);
|
||||
} else {
|
||||
// Produktion: Assets von der aktuellen Domain laden (kein CORS nötig)
|
||||
Vite::useScriptTagAttributes(['crossorigin' => false]);
|
||||
Vite::useStyleTagAttributes(['crossorigin' => false]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
app/Scopes/PortalScope.php
Normal file
37
app/Scopes/PortalScope.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Scopes;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Services\CurrentPortalContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Scope;
|
||||
|
||||
/**
|
||||
* Filtert Eloquent-Queries automatisch auf das aktive Portal.
|
||||
*
|
||||
* Einträge mit portal = 'both' sind immer sichtbar.
|
||||
* Kein Filter wenn: kein Portal-Kontext gesetzt (CLI, Tests, Import-Commands).
|
||||
*
|
||||
* Deaktivieren für einzelne Queries:
|
||||
* Company::withoutGlobalScope(PortalScope::class)->get();
|
||||
*/
|
||||
class PortalScope implements Scope
|
||||
{
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$portal = CurrentPortalContext::get();
|
||||
|
||||
if ($portal === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $model->getTable();
|
||||
|
||||
$builder->where(function (Builder $q) use ($table, $portal): void {
|
||||
$q->where("{$table}.portal", $portal->value)
|
||||
->orWhere("{$table}.portal", Portal::Both->value);
|
||||
});
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue