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

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

View file

@ -0,0 +1,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);
}
}

View 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()
);
}
}

View 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;
}
}

View 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}
");
}
}

View 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}"),
};
}
}

View file

@ -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
}

View 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;
}
}

View 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;
}
}

View 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()
);
}
}
}

View 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()
);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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']],
],
);
}
}

View 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
View 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',
};
}
}

View 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',
};
}
}

View 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',
};
}
}

View 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
View 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',
};
}
}

View 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',
};
}
}

View 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',
};
}
}

View 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',
};
}
}

View file

@ -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';
}
}

View 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),
];
}
}

View 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');
}
}

View 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);
}
}

View 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);
}
}

View file

@ -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);
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View file

@ -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');
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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());
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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(),
]);
}
}

View 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);
}
}

View 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);
}
}

View 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
};
}
}

View 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);
}
}

View 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);
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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()),
];
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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;
}
}

View 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',
);
}
}

View 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 [];
}
}

View 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');
}
}

View 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');
}
}

View 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;
}
}

View 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);
}
}

View 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
View 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');
}
}

View 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
View 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;
}
}

View 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
View 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
View 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
View 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);
}
}

View 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);
}
}

View 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',
];
}
}

View 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
View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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
View 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');
}
}

View 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;
}
}
}

View 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
View 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);
}
}

View file

@ -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']);
}
}

View 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);
}
}

View 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');
}
}

View 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);
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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);
}
}

View 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;
}
}

View file

@ -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']);
}
}
}

View file

@ -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);
}
/**

View file

@ -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]);
}
}
}

View 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);
});
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more