Release: Go-Live Pressekonto (20.07.2026)
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

Merge von fix/zeitzone-europe-berlin nach main — kompletter Stand des
Go-Lives: Legacy-Migration (Import, Medien, Timestamps, Verify grün),
Stripe-Live-Betrieb, Legacy-API-Kompatibilität inkl. Key-Import,
Performance-Fixes der öffentlichen Seiten (SWR-Caching + Feed-Index),
kanonische www-Redirects, Favicons, Betriebs-Doku (dev/betrieb, Roadmap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-21 07:55:59 +00:00
commit 7b27ec5a23
1050 changed files with 42869 additions and 426978 deletions

View file

@ -1,25 +1,67 @@
APP_NAME=Laravel
APP_NAME=Pressekonto
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=https://pressekonto.test
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_LOCALE=de
APP_FALLBACK_LOCALE=de
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# HTTP Basic Auth Gate (Staging-Schutz). Nur erzwungen, wenn aktiviert
# UND BASIC_AUTH_USER/BASIC_AUTH_PASSWORD gesetzt sind.
BASIC_AUTH_ENABLED=true
BASIC_AUTH_USER=
BASIC_AUTH_PASSWORD=
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
# Prod: auf `daily` stellen — der `single`-Channel wächst sonst unbegrenzt.
# Der daily-Channel rotiert mit LOG_DAILY_DAYS (Default 14, siehe Runbook).
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Betriebs-Alerts (R5.3): fehlgeschlagene Scheduler-Tasks/Queue-Jobs werden als
# `critical` geloggt und – wenn aktiviert – zusätzlich an MONITORING_ALERT_EMAIL
# gemeldet (Fallback: LEGAL_CONTACT_EMAIL). In Prod echten Mailer voraussetzen.
MONITORING_ALERTS_ENABLED=true
MONITORING_ALERT_EMAIL=
# ---------------------------------------------------------------------------
# Multi-Domain-Setup (config/domains.php)
# Hub: pressekonto (Portal-Backend + Hub-Landing) — Spokes: presseecho,
# businessportal24. Prod: .test durch echte Domains ersetzen
# (pressekonto.de, presseecho.de, businessportal24.com).
# ---------------------------------------------------------------------------
APP_PROTOCOL="https://"
APP_PORTAL_NAME="pressekonto.test"
APP_PORTAL_URL="https://pressekonto.test"
APP_PORTAL_PRIMARY="#526266"
APP_PORTAL_SECONDARY="#82a0a7"
# APP_PORTAL_ASSET_URL=
APP_PRESSEECHO_NAME=presseecho.test
APP_PRESSEECHO_URL=https://presseecho.test
APP_PRESSEECHO_PRIMARY="#345636"
APP_PRESSEECHO_SECONDARY="#6b8f71"
APP_BUSINESSPORTAL_NAME=businessportal24.test
APP_BUSINESSPORTAL_URL=https://businessportal24.test
APP_BUSINESSPORTAL_PRIMARY="#cf3628"
APP_BUSINESSPORTAL_SECONDARY="#f0834a"
# Sanctum: Hosts, die stateful (Session-Cookie) auf die API zugreifen dürfen.
# Prod: echte Domains kommasepariert eintragen.
SANCTUM_STATEFUL_DOMAINS=pressekonto.test,presseecho.test,businessportal24.test
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
@ -27,11 +69,27 @@ DB_CONNECTION=sqlite
# DB_USERNAME=root
# DB_PASSWORD=
# ---------------------------------------------------------------------------
# Legacy-Datenbanken (Alt-Systeme, read-only für Migration/Abgleich).
# Die businessportal-Verbindung ist ein Snapshot-Dump, nicht live.
# ---------------------------------------------------------------------------
DB_HOST_BUSINESSPORTAL=127.0.0.1
DB_DATABASE_BUSINESSPORTAL=
DB_USERNAME_BUSINESSPORTAL=
DB_PASSWORD_BUSINESSPORTAL=
DB_HOST_PRESSEECHO=127.0.0.1
DB_DATABASE_PRESSEECHO=
DB_USERNAME_PRESSEECHO=
DB_PASSWORD_PRESSEECHO=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# Prod: true setzen (Cookies nur über HTTPS).
# SESSION_SECURE_COOKIE=true
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@ -47,6 +105,7 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Prod: echten Mailer konfigurieren — Default `log` versendet NICHTS.
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
@ -63,6 +122,64 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Lokaler Vite-Dev-Server (dual-port Setup, siehe VITE-SETUP.md).
VITE_DEV_SERVER_URL=https://assets.pressekonto.test
# ---------------------------------------------------------------------------
# Stripe / Billing (Laravel Cashier)
# Prod: Live-Keys + Live-Preis-IDs eintragen [EXTERN: R1.2].
# ---------------------------------------------------------------------------
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
# Stripe-Preis-ID des Einzelkaufs (eine PM, 19 €).
STRIPE_PRICE_SINGLE_PM=
CASHIER_CURRENCY=eur
CASHIER_CURRENCY_LOCALE=de_DE
# Umsatzsteuer/Rechnungsangaben.
# BILLING_VAT_RATE=0.19
# BILLING_DEFAULT_CURRENCY=EUR
# BILLING_MANUAL_DUE_DAYS=14
BILLING_OWN_VAT_ID=
# Abo-/Bestandsschutz-Gate (WS-5). Default (auch ohne Env-Key) ist true:
# Einreichen zur Prüfung erfordert ein aktives Abo, einen bezahlten Einzelkauf
# oder Bestandsschutz (grandfathered) — siehe User::hasActiveBooking().
# Nur für lokale Entwicklung bewusst auf false setzen (Gate offen für alle).
BILLING_ENFORCE_BOOKING=true
# ---------------------------------------------------------------------------
# KI: Klassifikation (Red-Flag-Gate) + Content-Score (config/scoring.php).
# Ohne OPENAI_API_KEY laufen die Jobs nicht — Queue-Worker erforderlich.
# ---------------------------------------------------------------------------
OPENAI_API_KEY=
# OPENAI_API_URL=
# OPENAI_MODEL=
# OPENAI_TIMEOUT=30
# CLASSIFICATION_PROVIDER=openai
# CLASSIFICATION_MODEL=
# CLASSIFICATION_TIMEOUT=15
# CLASSIFICATION_GREEN_DELAY=0
# CLASSIFICATION_YELLOW_MANUAL=true
# CONTENT_SCORE_PROVIDER=openai
# CONTENT_SCORE_MODEL=
# CONTENT_SCORE_TIMEOUT=30
# CONTENT_SCORE_HOCHWERTIG=80
# CONTENT_SCORE_GEPRUEFT=60
# ---------------------------------------------------------------------------
# Newsletter-Sync (config/newsletter.php). Provider `placeholder` bindet den
# Null-Client; enabled=true mit anderem Provider wirft bewusst, bis eine
# echte Anbindung implementiert ist.
# ---------------------------------------------------------------------------
NEWSLETTER_SYNC_ENABLED=false
NEWSLETTER_SYNC_PROVIDER=placeholder
NEWSLETTER_SYNC_ENDPOINT=
NEWSLETTER_SYNC_API_KEY=
# NEWSLETTER_SYNC_TIMEOUT=10
# Google-Login (Laravel Socialite). Die Redirect-URI muss in der Google Cloud
# Console EXAKT der Callback-URL der Portal-Domain entsprechen (Login läuft auf
@ -73,8 +190,29 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://pressekonto.com/auth/google/callback
# Abo-/Bestandsschutz-Gate (WS-5). Ist der Schalter aus, darf jeder einreichen
# (Dev-Default). Für den Launch auf true setzen: Einreichen zur Prüfung erfordert
# dann ein aktives Abo, einen bezahlten Einzelkauf oder Bestandsschutz
# (grandfathered). Siehe User::hasActiveBooking().
BILLING_ENFORCE_BOOKING=false
# Empfänger des öffentlichen Kontaktformulars je Brand. Ohne Wert fällt der
# Versand auf MAIL_FROM_ADDRESS zurück. [EXTERN: echte Adressen eintragen]
CONTACT_EMAIL_PRESSEKONTO=
CONTACT_EMAIL_PRESSEECHO=
CONTACT_EMAIL_BUSINESSPORTAL=
# Depublikation (T12): Netto-Preis in Cent fuer die freiwillige Entfernung
# einer veroeffentlichten PM aus der oeffentlichen Ansicht (Tombstone bleibt).
# Default 3500 (35 EUR netto, Entscheidung O6 vom 06.07.2026); 0 deaktiviert.
BILLING_DEPUBLICATION_PRICE_CENTS=3500
# Selbst gehostete Umami-Webanalyse (sys.adametz.media; IDs je Brand in
# config/domains.php). Erst beim Go-Live der Webseiten auf true setzen —
# die Datenschutzerklärung (Abschnitt 5) beschreibt den Einsatz bereits.
UMAMI_ENABLED=false
# Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB,
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.
# Ohne Wert gilt der Default info@pressekonto.com (config/legal.php).
LEGAL_CONTACT_EMAIL=
# Optionales separates DSA-Melde-Postfach je Brand (Impressum + /melden).
# Ohne Wert fällt die Anzeige auf LEGAL_CONTACT_EMAIL zurück.
REPORT_EMAIL_PRESSEKONTO=
REPORT_EMAIL_PRESSEECHO=
REPORT_EMAIL_BUSINESSPORTAL=

9
.gitignore vendored
View file

@ -57,4 +57,11 @@ _static/
_work/
_storage/
_businessportal24.com/
dev/migration/
dev/migration/
# Produktivdaten: Legacy-SQL-Dumps bleiben lokal, nie ins Repo (Entscheidung 06.07.2026)
dev/archive/migration-2026/sql/
# Produktivdaten: API-Aktivitaets-Logs der Altportale (Kundendaten + Alt-API-Keys), analog SQL-Dumps
dev/api/
# Rechtstext-Quellen des Users (untracked Arbeitsstand)
docs/legal/

View file

@ -2,6 +2,7 @@
namespace App\Actions\Admin;
use App\Listeners\RecordAuthActivity;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
@ -20,7 +21,8 @@ class UserImpersonation
return $this->canInitiate($admin)
&& ! $admin->is($target)
&& ! $this->isActive()
&& $this->canBeImpersonated($target);
&& $this->canBeImpersonated($target)
&& $this->mayImpersonateRank($admin, $target);
}
public function canBeImpersonated(User $target): bool
@ -28,6 +30,16 @@ class UserImpersonation
return $target->canAccessCustomer();
}
/**
* Rang-Schutz: Ein Super-Admin darf nur von einem Super-Admin im Panel
* angemeldet werden. Verhindert, dass ein Admin (users:manage) das mächtigere
* Super-Admin-Konto übernimmt und in dessen Namen z. B. API-Token erzeugt.
*/
public function mayImpersonateRank(User $admin, User $target): bool
{
return $admin->is_super_admin || ! $target->is_super_admin;
}
/**
* @throws AuthorizationException
*/
@ -49,8 +61,13 @@ class UserImpersonation
throw new AuthorizationException(__('Dieser Benutzer kann nicht im Panel angemeldet werden.'));
}
if (! $this->mayImpersonateRank($admin, $target)) {
throw new AuthorizationException(__('Super-Admins können nur von Super-Admins im Panel angemeldet werden.'));
}
session([self::SessionKey => $admin->getKey()]);
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'impersonation');
Auth::login($target);
}
@ -70,6 +87,7 @@ class UserImpersonation
return null;
}
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'impersonation');
Auth::login($admin);
return $admin;

View file

@ -0,0 +1,141 @@
<?php
namespace App\Authorization;
use App\Services\Admin\AdminSystemHealth;
/**
* Zentraler Katalog der Spatie-Permissions. Einzige Quelle für die
* Permission-Namen und die Rollen-Zuordnung — genutzt vom
* RolesAndPermissionsSeeder und von der Betriebsüberwachung
* ({@see AdminSystemHealth}). Blade-/Route-Gates verwenden
* weiterhin die String-Literale (Laravel-idiomatisch), deren kanonische
* Definition aber hier liegt.
*/
final class Permission
{
public const PressReleasesRead = 'press-releases:read';
public const PressReleasesWrite = 'press-releases:write';
public const PressReleasesPublish = 'press-releases:publish';
public const PressReleaseImagesWrite = 'press-release-images:write';
public const CompaniesRead = 'companies:read';
public const CompaniesWrite = 'companies:write';
public const CategoriesManage = 'categories:manage';
public const FooterCodesManage = 'footer-codes:manage';
public const BillingManage = 'billing:manage';
public const ComplianceManage = 'compliance:manage';
public const SettingsManage = 'settings:manage';
public const NewsletterSubscribe = 'newsletter:subscribe';
public const UsersManage = 'users:manage';
public const RolesManage = 'roles:manage';
public const ApiImmediatePublication = 'api:immediate-publication';
/**
* Alle existierenden Permissions.
*
* @return list<string>
*/
public static function all(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleasesPublish,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::CompaniesWrite,
self::CategoriesManage,
self::FooterCodesManage,
self::BillingManage,
self::ComplianceManage,
self::SettingsManage,
self::NewsletterSubscribe,
self::UsersManage,
self::RolesManage,
self::ApiImmediatePublication,
];
}
/**
* Nur an `admin` vergebene Verwaltungsrechte (Befund H2). Basis für den
* Deployment-Check „Berechtigungen ausgerollt".
*
* @return list<string>
*/
public static function adminOnly(): array
{
return [
self::CompaniesWrite,
self::CategoriesManage,
self::FooterCodesManage,
self::BillingManage,
self::ComplianceManage,
self::SettingsManage,
self::UsersManage,
self::RolesManage,
];
}
/**
* Permissions der Rolle `editor` (Content-Rolle ohne destruktive/Verwaltung).
*
* @return list<string>
*/
public static function editor(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleasesPublish,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::NewsletterSubscribe,
];
}
/**
* Permissions der Rolle `customer`.
*
* @return list<string>
*/
public static function customer(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::NewsletterSubscribe,
];
}
/**
* Permissions der Rolle `api-only`.
*
* @return list<string>
*/
public static function apiOnly(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::NewsletterSubscribe,
];
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Auth\UserRolePermissionSyncService;
use Illuminate\Console\Command;
/**
* Weist aktiven, komplett rollenlosen Nutzern eine Portal-Rolle zu.
*
* Hintergrund: Guest-/Auth-Redirects lenken einen eingeloggten Nutzer ohne
* admin/editor/customer-Rolle auf die Startseite (bootstrap/app.php →
* redirectUsersTo). Legacy-Importe oder ein fehlgeschlagener Rollen-Sync nach
* dem User-Insert können solche „hängenden" Konten erzeugen. Dieser Nachlauf
* heilt sie. api-only-Konten besitzen bereits eine Rolle und werden bewusst
* NICHT angefasst (kein Portal-Zugriff gewollt).
*/
class BackfillPortalRole extends Command
{
protected $signature = 'users:backfill-portal-role
{--role=customer : Zuzuweisende Rolle}
{--dry-run : Nur zählen, nichts schreiben}';
protected $description = 'Weist aktiven, rollenlosen Nutzern eine Portal-Rolle (Standard: customer) zu.';
public function handle(UserRolePermissionSyncService $roleSync): int
{
$role = (string) $this->option('role');
$dryRun = (bool) $this->option('dry-run');
$ids = User::query()
->where('is_active', true)
->whereDoesntHave('roles')
->pluck('id');
if ($ids->isEmpty()) {
$this->info('Keine aktiven rollenlosen Nutzer gefunden – nichts zu tun.');
return self::SUCCESS;
}
if ($dryRun) {
$this->warn("[DRY-RUN] {$ids->count()} aktive rollenlose Nutzer würden die Rolle '{$role}' erhalten.");
return self::SUCCESS;
}
$assigned = 0;
foreach ($ids->chunk(500) as $chunk) {
User::query()->whereIn('id', $chunk)->get()->each(function (User $user) use ($roleSync, $role, &$assigned): void {
$roleSync->assignRoleAndSyncPermissions($user, $role);
$assigned++;
});
}
$this->info("{$assigned} aktiven rollenlosen Nutzern die Rolle '{$role}' zugewiesen.");
return self::SUCCESS;
}
}

View file

@ -26,9 +26,10 @@ class FixLegacyTimestamps extends Command
protected $description = 'Korrigiert created_at/updated_at auf importierten Datensätzen aus den Legacy-DBs.';
private const PORTAL_DB = [
'presseecho' => 'presseecho',
'businessportal24' => 'businessportal',
/** Mapping: Portal → Legacy-Connection; der Schema-Name kommt aus deren Config (.env). */
private const PORTAL_CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
/** Mapping: neue Tabelle → (legacy_portal, legacy_db, legacy_table, legacy_created_col) */
@ -69,7 +70,7 @@ class FixLegacyTimestamps extends Command
$isDryRun = (bool) $this->option('dry-run');
$entities = $entityOpt === 'all' ? array_keys(self::ENTITY_CONFIG) : [$entityOpt];
$portals = $portalOpt === 'all' ? array_keys(self::PORTAL_DB) : [$portalOpt];
$portals = $portalOpt === 'all' ? array_keys(self::PORTAL_CONNECTIONS) : [$portalOpt];
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
@ -87,9 +88,9 @@ class FixLegacyTimestamps extends Command
}
foreach ($portals as $portal) {
$legacyDb = self::PORTAL_DB[$portal] ?? null;
$legacyDb = $this->legacySchema($portal);
if (! $legacyDb) {
$this->error("Unbekanntes Portal: {$portal}");
$this->error("Unbekanntes Portal oder fehlende Legacy-DB-Config: {$portal}");
continue;
}
@ -108,6 +109,19 @@ class FixLegacyTimestamps extends Command
return self::SUCCESS;
}
private function legacySchema(string $portal): ?string
{
$connection = self::PORTAL_CONNECTIONS[$portal] ?? null;
if ($connection === null) {
return null;
}
$database = config("database.connections.{$connection}.database");
return is_string($database) && $database !== '' ? $database : null;
}
private function fixEntity(array $config, string $portal, string $legacyDb, bool $isDryRun): int
{
$newTable = $config['new_table'];

View file

@ -258,7 +258,7 @@ class GrandfatherLegacySubscriptions extends Command
'type' => 'recurring',
// Katalogpreise sind netto (Entscheidung 12.06.2026).
'price_cents' => $candidate['legacy_conditions']['net_cents'],
'currency' => 'EUR',
'currency' => config('billing.default_currency'),
'interval' => 'yearly',
'is_hidden' => true,
],

View file

@ -0,0 +1,82 @@
<?php
namespace App\Console\Commands;
use App\Services\Api\LegacyApiKeyImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Importiert die Legacy-api_keys der Altportale als Sanctum-Tokens
* (Übernahme bestehender API-Integrationen, 06.07.2026). Quelle sind die
* read-only Legacy-Verbindungen; berechtigt ist, wer auch selbst ein Token
* erstellen dürfte (aktiver Zahlungsstatus/Bestandsschutz). Wiederholbar:
* bereits importierte Schlüssel werden übersprungen.
*
* Verwendung:
* php artisan legacy:import-api-keys --dry-run
* php artisan legacy:import-api-keys --portal=businessportal24
*/
class ImportLegacyApiKeys extends Command
{
protected $signature = 'legacy:import-api-keys
{--portal=all : presseecho | businessportal24 | all}
{--dry-run : Nur anzeigen, nichts schreiben}';
protected $description = 'Übernimmt die api_keys der Legacy-Portale als Sanctum-Tokens (bestehende Integrationen bleiben gültig).';
private const CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
public function handle(LegacyApiKeyImporter $importer): int
{
$portalOption = (string) $this->option('portal');
$dryRun = (bool) $this->option('dry-run');
$portals = $portalOption === 'all' ? array_keys(self::CONNECTIONS) : [$portalOption];
if (array_diff($portals, array_keys(self::CONNECTIONS)) !== []) {
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
foreach ($portals as $portal) {
$rows = DB::connection(self::CONNECTIONS[$portal])
->table('sf_guard_user_profile')
->whereNotNull('api_key')
->where('api_key', '!=', '')
->get(['user_id', 'api_key'])
->map(fn (object $row): array => [
'legacy_id' => (int) $row->user_id,
'api_key' => (string) $row->api_key,
]);
$result = $importer->import($portal, $rows, $dryRun);
$this->table(
['Portal', 'Keys in Legacy-DB', 'Importiert', 'Schon vorhanden', 'Nicht berechtigt', 'Kein User-Mapping'],
[[
$portal,
$rows->count(),
$result['imported'],
$result['already_present'],
$result['ineligible'],
$result['unknown_user'],
]],
);
foreach ($result['ineligible_users'] as $email) {
$this->warn("Nicht berechtigt (kein aktiver Zahlungsstatus): {$email}");
}
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use App\Services\Billing\LegacyBillingAddressImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Übernimmt die Rechnungsadressen der Bestandskunden aus dem Rechnungsarchiv
* (Befund 06.07.2026: ohne Adresse überspringt der MAN-Rechnungslauf die
* Vereinbarung und der Bestandsschutz läuft am Periodenende aus). Läuft
* NACH legacy:grandfather-subscriptions; die Legacy-Verbindungen werden nur
* für die Länderauflösung (country_id → Name) gebraucht. Wiederholbar:
* bestehende Adressen werden nie überschrieben.
*
* Verwendung:
* php artisan legacy:import-billing-addresses --dry-run
* php artisan legacy:import-billing-addresses
*/
class ImportLegacyBillingAddresses extends Command
{
protected $signature = 'legacy:import-billing-addresses
{--dry-run : Nur anzeigen, nichts schreiben}';
protected $description = 'Übernimmt Rechnungsadressen der grandfathered Bestandskunden aus dem Legacy-Rechnungsarchiv.';
private const CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
public function handle(LegacyBillingAddressImporter $importer): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$countryNamesByPortal = [];
foreach (self::CONNECTIONS as $portal => $connection) {
$countryNamesByPortal[$portal] = DB::connection($connection)
->table('country')
->pluck('name', 'id')
->map(fn ($name): string => (string) $name)
->all();
}
$report = $importer->import($countryNamesByPortal, $dryRun);
$this->table(
['Importiert', 'Adresse vorhanden', 'Nicht parsebar', 'Land unbekannt', 'Quell-Rechnung fehlt'],
[[
$report['imported'],
$report['skipped_existing'],
count($report['unparseable']),
count($report['unknown_country']),
count($report['missing_invoice']),
]],
);
foreach ($report['unparseable'] as $line) {
$this->warn("Manuell nachpflegen (Adresse nicht parsebar): {$line}");
}
foreach ($report['unknown_country'] as $line) {
$this->warn("Manuell nachpflegen (Land nicht zuordenbar): {$line}");
}
foreach ($report['missing_invoice'] as $email) {
$this->warn("Manuell prüfen (Quell-Rechnung nicht im Archiv): {$email}");
}
return self::SUCCESS;
}
}

View file

@ -12,6 +12,7 @@ use App\Services\Import\PressReleaseImporter;
use App\Services\Import\UserAssociationLinker;
use App\Services\Import\UserImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Legacy-Import-Master-Command (Phase 6 – Datenmigration).
@ -22,6 +23,7 @@ use Illuminate\Console\Command;
* 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
* php artisan legacy:import --source=presseecho --since=today --force ← Delta: nur heute Angelegtes/Geändertes
*
* Schritte (--step):
* categories – Kategorien + Translations (einmalig, beide Portale identisch)
@ -39,6 +41,7 @@ class ImportLegacyData extends Command
{--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}
{--since= : Nur Legacy-Zeilen mit created_at/updated_at ab diesem Zeitpunkt (z. B. "2026-07-20", "today"); für Updates mit --force kombinieren}
{--chunk-size=500 : Batch-Größe (Debugging)}';
protected $description = 'Legacy-Daten aus Presseecho / Businessportal24 in die neue DB importieren.';
@ -61,9 +64,29 @@ class ImportLegacyData extends Command
$isDryRun = (bool) $this->option('dry-run');
$isForce = (bool) $this->option('force');
$since = null;
if (filled($this->option('since'))) {
try {
$since = Carbon::parse((string) $this->option('since'), config('app.timezone'));
} catch (\Throwable) {
$this->error('Ungültiger --since-Wert: '.$this->option('since'));
return self::FAILURE;
}
}
$portals = $source === 'all' ? self::PORTALS : [$source];
$steps = $step === 'all' ? self::STEPS : [$step];
if ($since) {
$this->info('Delta-Modus: nur Legacy-Zeilen mit created_at/updated_at ab '.$since->format('d.m.Y H:i:s').'.');
if (! $isForce) {
$this->warn('Hinweis: ohne --force werden im Zeitfenster GEÄNDERTE, bereits importierte Datensätze übersprungen — nur Neue kommen dazu.');
}
}
if (! $isDryRun && ! $this->option('force')) {
$this->warn('WICHTIG: Dieser Command schreibt Daten in die Produktions-DB.');
$this->line(' Portale: '.implode(', ', $portals));
@ -86,7 +109,7 @@ class ImportLegacyData extends Command
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);
$ctx = new ImportContext('all', $isDryRun, $isForce, $since);
$this->info('▶ Schritt [link-associations] (beide Portale, neue DB)');
$start = microtime(true);
$result = app(UserAssociationLinker::class)->run($ctx);
@ -100,7 +123,7 @@ class ImportLegacyData extends Command
$stepPortals = ($currentStep === 'categories') ? [$portals[0]] : $portals;
foreach ($stepPortals as $portal) {
$ctx = new ImportContext($portal, $isDryRun, $isForce);
$ctx = new ImportContext($portal, $isDryRun, $isForce, $since);
$this->info("▶ Schritt [{$currentStep}] Portal [{$portal}]");
$start = microtime(true);
@ -120,6 +143,16 @@ class ImportLegacyData extends Command
}
}
// Selbstheilung: Ein User-Insert (updateOrCreate) und die anschließende
// Rollen-Zuweisung sind nicht atomar – schlägt der Rollen-Sync nach dem
// Insert fehl, bliebe ein aktives, rollenloses Konto zurück (hängt beim
// Login auf der Startseite). Nach einem User-Import daher absichern.
if (! $isDryRun && in_array('users', $steps, true)) {
$this->newLine();
$this->info('▶ Nachlauf: aktive rollenlose Nutzer absichern');
$this->call('users:backfill-portal-role');
}
$total = round(microtime(true) - $totalStart, 1);
$this->newLine();
$this->info("Import abgeschlossen in {$total}s.");

View file

@ -0,0 +1,131 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyType;
use App\Models\Company;
use App\Models\User;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Übernimmt die Namen der Legacy-Agentur-Firmen (companies.type = agency)
* in das Profilfeld users.agency_name des jeweiligen Owners — Teil des
* Umbaus Agentur-Attribution → User-Profil (dev/agentur-attribution/
* entwicklungsplan.md, A2). Läuft im Go-Live NACH dem Firmen-/User-Import
* und VOR companies:retire-agency-type. Wiederholbar: bereits gesetzte
* agency_name-Werte werden nie überschrieben.
*
* Verwendung:
* php artisan legacy:migrate-agency-names --dry-run
* php artisan legacy:migrate-agency-names
*/
class MigrateLegacyAgencyNames extends Command
{
protected $signature = 'legacy:migrate-agency-names
{--dry-run : Nur anzeigen und Report schreiben, keine User ändern}';
protected $description = 'Übernimmt Namen der Legacy-Agentur-Firmen in das Profilfeld users.agency_name.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
// Bewusst ohne is_active-Filter: die Attributions-Zeile hat auch
// inaktive Agentur-Firmen angezeigt; dieses Verhalten bleibt erhalten.
$agencies = Company::withoutGlobalScope(PortalScope::class)
->where('type', CompanyType::Agency)
->get(['id', 'portal', 'owner_user_id', 'name', 'is_active', 'updated_at']);
$report = [
'generated_at' => now()->toIso8601String(),
'dry_run' => $dryRun,
'summary' => [],
'multiple_names' => [],
'skipped_existing' => [],
'ownerless' => [],
'missing_owner' => [],
];
foreach ($agencies->whereNull('owner_user_id') as $orphan) {
$report['ownerless'][] = [
'company_id' => $orphan->id,
'portal' => $orphan->portal?->value,
'name' => $orphan->name,
];
}
$byOwner = $agencies->whereNotNull('owner_user_id')->groupBy('owner_user_id');
$users = User::withTrashed()->whereIn('id', $byOwner->keys())->get()->keyBy('id');
$migrated = 0;
foreach ($byOwner as $userId => $companies) {
$user = $users->get($userId);
if ($user === null) {
$report['missing_owner'][] = [
'user_id' => $userId,
'company_ids' => $companies->pluck('id')->all(),
];
continue;
}
/** @var Company $winner Zuletzt aktualisierter Datensatz gewinnt (Regel Mehrfach-Fälle). */
$winner = $companies->sortByDesc('updated_at')->first();
$variants = $companies->pluck('name')->unique()->values();
if ($variants->count() > 1) {
$report['multiple_names'][] = [
'user_id' => $user->id,
'email' => $user->email,
'chosen' => $winner->name,
'variants' => $variants->all(),
];
}
if (filled($user->agency_name)) {
$report['skipped_existing'][] = [
'user_id' => $user->id,
'email' => $user->email,
'existing' => $user->agency_name,
'candidate' => $winner->name,
];
continue;
}
if (! $dryRun) {
$user->update(['agency_name' => $winner->name]);
}
$migrated++;
}
$report['summary'] = [
'agency_companies' => $agencies->count(),
'users_migrated' => $migrated,
'users_skipped_existing' => count($report['skipped_existing']),
'users_multiple_names' => count($report['multiple_names']),
'companies_ownerless' => count($report['ownerless']),
'owners_missing' => count($report['missing_owner']),
];
$this->table(
['Agentur-Firmen', 'User migriert', 'Bereits gesetzt', 'Mehrfach-Namen', 'Ohne Owner', 'Owner fehlt'],
[array_values($report['summary'])],
);
$path = 'migration/agency-names-'.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 self::SUCCESS;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Services\PressRelease\PressReleaseMediaPrivatizer;
use Illuminate\Console\Command;
class PrivatizePressReleaseMedia extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'press-releases:privatize-media
{--prune-missing : Unmigrierte Legacy-Bildzeilen ohne Datei endgültig löschen}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Verschiebt bestehende Pressemitteilungs-Medien vom öffentlichen auf den privaten Datenträger';
/**
* Execute the console command.
*/
public function handle(PressReleaseMediaPrivatizer $privatizer): int
{
$counts = $privatizer->privatizeAll((bool) $this->option('prune-missing'));
$this->info(sprintf(
'%d Bilder und %d Anhänge wurden auf den privaten Datenträger migriert.',
$counts['images'],
$counts['attachments'],
));
if ($counts['legacy_pruned'] > 0) {
$this->info(sprintf(
'%d unmigrierte Legacy-Bildzeilen ohne Datei wurden gelöscht.',
$counts['legacy_pruned'],
));
}
if ($counts['legacy_skipped'] > 0) {
$this->warn(sprintf(
'%d unmigrierte Legacy-Bildzeilen übersprungen — erst `legacy:migrate-media` ausführen, danach Verbliebene ohne Datei mit `--prune-missing` löschen.',
$counts['legacy_skipped'],
));
}
return self::SUCCESS;
}
}

View file

@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Console\Command;
@ -47,7 +48,7 @@ class PublishScheduledPressReleases extends Command
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
// Fallback in der manuellen Queue.
$candidates = PressRelease::withoutGlobalScopes()
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
->where('status', PressReleaseStatus::Review->value)
->whereIn('classification', [
PressReleaseClassification::Green->value,

View file

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Services\Web\MarketDataService;
use Illuminate\Console\Command;
class RefreshMarketData extends Command
{
protected $signature = 'market:refresh';
protected $description = 'Kursdaten (DAX, EUR/USD, Brent) für den Web-Ticker abrufen und cachen';
public function handle(MarketDataService $marketData): int
{
if (! config('market.enabled')) {
$this->info('Marktdaten sind deaktiviert (MARKET_DATA_ENABLED=false).');
return self::SUCCESS;
}
$slides = $marketData->refresh();
if ($slides === []) {
$this->warn('Keine Kursdaten erhalten — Cache unverändert.');
return self::FAILURE;
}
foreach ($slides as $slide) {
$this->line(sprintf('%s: %s (%s)', $slide['label'], $slide['value'], $slide['delta']));
}
return self::SUCCESS;
}
}

View file

@ -8,8 +8,12 @@ use Illuminate\Console\Command;
/**
* Setzt den monatlichen PM-Kontingent-Verbrauch aller User zurück.
*
* Temporärer Stub bis zum echten Tarif-/Credit-Modul. Läuft per Scheduler
* am Monatsanfang (siehe routes/console.php).
* Fester Bestandteil des Tarif-Modells (geprüft R1.3, 03.07.2026): Die
* Plan-Kontingente (§2/§3.3 Decision-Update) gelten pro Kalendermonat;
* User::pressReleaseQuotaRemaining() rechnet gegen diesen Zähler. Läuft
* per Scheduler am Monatsersten (siehe routes/console.php). Bewusste
* Vereinfachung: Reset am Kalendermonat, nicht an der individuellen
* Abo-Periode ausgerichtet.
*/
class ResetMonthlyPressReleaseQuota extends Command
{

View file

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyType;
use App\Models\Company;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Storage;
/**
* Schafft den Firmen-Typ „Agentur" ab (dev/agentur-attribution/
* entwicklungsplan.md, A4): Agentur-Firmen mit PMs oder Pressekontakten
* werden zu normalen Firmen umtypisiert (sie sind faktisch Newsrooms),
* leere Agentur-Firmen werden soft-gelöscht. Nie gelöscht wird, wenn
* Dritte per company_user Mitglied sind oder eine Zahlungsoptions-
* Verknüpfung (user_payment_option_company) existiert — dann wird nur
* umtypisiert. Läuft im Go-Live NACH legacy:migrate-agency-names.
*
* Verwendung:
* php artisan companies:retire-agency-type --dry-run
* php artisan companies:retire-agency-type
*/
class RetireAgencyCompanyType extends Command
{
protected $signature = 'companies:retire-agency-type
{--dry-run : Nur anzeigen und Report schreiben, keine Firmen ändern}';
protected $description = 'Typisiert Legacy-Agentur-Firmen zu normalen Firmen um bzw. soft-löscht leere Agentur-Firmen.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$agencies = Company::withoutGlobalScope(PortalScope::class)
->where('type', CompanyType::Agency)
->withExists([
// PMs inkl. soft-gelöschter und portalfremder Datensätze —
// eine Firma mit irgendeiner PM-Historie bleibt Newsroom.
'pressReleases as has_press_releases' => fn (Builder $query) => $query->withoutGlobalScopes(),
'contacts as has_contacts',
'userPaymentOptions as has_payment_option_links',
// Mitgliedschaften Dritter: jeder Pivot-Eintrag, der nicht der
// Owner selbst ist (bei Owner-losen Firmen jeder Eintrag).
'users as has_third_party_members' => fn (Builder $query) => $query->where(
fn (Builder $query) => $query
->whereColumn('company_user.user_id', '!=', 'companies.owner_user_id')
->orWhereNull('companies.owner_user_id'),
),
])
->get();
$report = [
'generated_at' => now()->toIso8601String(),
'dry_run' => $dryRun,
'summary' => [],
'retyped' => [],
'deleted' => [],
'protected' => [],
];
foreach ($agencies as $agency) {
$entry = [
'company_id' => $agency->id,
'portal' => $agency->portal?->value,
'name' => $agency->name,
];
$isNewsroom = $agency->has_press_releases || $agency->has_contacts;
$isProtected = $agency->has_third_party_members || $agency->has_payment_option_links;
if (! $isNewsroom && ! $isProtected) {
if (! $dryRun) {
$agency->delete();
}
$report['deleted'][] = $entry;
continue;
}
if (! $isNewsroom && $isProtected) {
$report['protected'][] = $entry + [
'third_party_members' => (bool) $agency->has_third_party_members,
'payment_option_links' => (bool) $agency->has_payment_option_links,
];
}
if (! $dryRun) {
$agency->update(['type' => CompanyType::Company]);
}
$report['retyped'][] = $entry;
}
$report['summary'] = [
'agency_companies' => $agencies->count(),
'retyped' => count($report['retyped']),
'deleted' => count($report['deleted']),
'protected' => count($report['protected']),
];
$this->table(
['Agentur-Firmen', 'Umtypisiert', 'Soft-gelöscht', 'Geschützt (nur umtypisiert)'],
[array_values($report['summary'])],
);
$path = 'migration/retire-agency-type-'.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 self::SUCCESS;
}
}

View file

@ -0,0 +1,213 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyType;
use App\Enums\PaymentOptionType;
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Api\LegacyApiKeyImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Permission;
/**
* Legt einen API-Testkunden nach connektar-Muster an (S4 der Paritäts-Doku
* dev/betrieb/api-legacy-paritaet.md): aktiver Bestandskunde mit Grandfathering,
* importiertem Legacy-Key, Sofort-Publish-Recht, Firma + Pressekontakt — und
* gibt Key + fertige curl-Sammlung für den /service-Flow aus.
*
* Idempotent: wiederholte Läufe erneuern nur den API-Key. Nur local/staging.
*/
class SeedLegacyApiTestdata extends Command
{
protected $signature = 'legacy:seed-api-testdata
{--portal=businessportal24 : Portal des Testkunden (businessportal24|presseecho)}
{--email=api-test@pressekonto.test : E-Mail des Testkunden}';
protected $description = 'API-Testdaten nach connektar-Muster anlegen (Legacy-Key, Firma, Kontakt) + curl-Sammlung ausgeben.';
public function handle(LegacyApiKeyImporter $importer): int
{
if (app()->environment('production')) {
$this->error('Nur fuer local/staging — nicht in Produktion ausfuehren.');
return self::FAILURE;
}
$portal = (string) $this->option('portal');
if (! in_array($portal, [Portal::Businessportal24->value, Portal::Presseecho->value], true)) {
$this->error('Ungueltiges Portal: '.$portal);
return self::FAILURE;
}
$email = (string) $this->option('email');
$legacyId = 999901;
// 1. Testkunde: aktiver Legacy-API-User (wie connektar).
$user = User::query()->firstOrCreate(
['email' => $email],
[
'name' => 'API Testkunde',
'password' => Hash::make('password'),
'email_verified_at' => now(),
],
);
$user->forceFill([
'is_active' => true,
'portal' => $portal,
'legacy_portal' => $portal,
'legacy_id' => $legacyId,
'registration_type' => RegistrationType::ApiUser,
'agency_name' => 'API Testverteiler',
])->save();
if (! $user->hasRole('customer')) {
$user->assignRole('customer');
}
// 2. Grandfathering (Bestandsschutz): macht den User API-berechtigt
// und laesst das Buchungs-Gate passieren.
$paymentOption = PaymentOption::query()->firstOrCreate(
['article_number' => 'ART-API-TEST'],
[
'type' => PaymentOptionType::cases()[0]->value,
'price_cents' => 0,
'currency' => 'EUR',
'interval' => 'monthly',
'is_hidden' => true,
],
);
UserPaymentOption::query()->updateOrCreate(
['user_id' => $user->id, 'payment_option_id' => $paymentOption->id],
[
'status' => UserPaymentOptionStatus::Grandfathered->value,
'grandfathered_until' => null,
'current_period_start' => now()->subMonth(),
'current_period_end' => now()->addMonths(11),
'stripe_subscription_id' => null,
],
);
// 3. Sofort-Publish-Privileg (Alt-Verhalten von connektar). Die
// Permission defensiv anlegen — lokale DBs sind nicht immer auf dem
// juengsten Seeder-Stand.
Permission::firstOrCreate(['name' => 'api:immediate-publication']);
if (! $user->hasPermissionTo('api:immediate-publication')) {
$user->givePermissionTo('api:immediate-publication');
}
// 4. Firma + Pressekontakt (der API-Flow kann weitere anlegen).
$company = Company::withoutGlobalScopes()->firstOrNew(
['name' => 'API Test GmbH', 'portal' => $portal],
[
'type' => CompanyType::Company->value,
'address' => "Teststrasse 1\n33602 Bielefeld",
'country_code' => 'DE',
'email' => 'firma@api-test.example',
'website' => 'https://api-test.example',
'is_active' => true,
],
);
if (! $company->slug) {
$company->slug = $company->generateUniqueSlug($company->name);
}
$company->owner_user_id ??= $user->id;
$company->save();
if (! $company->users()->whereKey($user->id)->exists()) {
$company->users()->attach($user->id, ['role' => 'owner']);
}
$contact = $company->contacts()->firstOrCreate(
['email' => 'presse@api-test.example'],
[
'portal' => $portal,
'salutation_key' => 'mr',
'first_name' => 'Max',
'last_name' => 'Mustermann',
'responsibility' => 'Pressestelle',
],
);
// 5. Legacy-API-Key erneuern (wie legacy:import-api-keys).
$apiKey = 'test-'.Str::random(40);
$user->tokens()->delete();
$importer->import($portal, [
['legacy_id' => $legacyId, 'api_key' => $apiKey],
]);
$category = Category::withoutGlobalScopes()->where('is_active', true)->first();
$host = rtrim((string) config("domains.domains.{$portal}.url", config('app.url')), '/');
$this->info('API-Testkunde bereit.');
$this->table(['Feld', 'Wert'], [
['Login (Panel)', $email.' / password'],
['Portal', $portal],
['X-ApiKey', $apiKey],
['Firma', "#{$company->id} {$company->name}"],
['Pressekontakt', "#{$contact->id} {$contact->first_name} {$contact->last_name}"],
['Kategorie (legacy_id)', $category ? "#{$category->legacy_id}" : '— (keine aktive Kategorie!)'],
['Sofort-Publish', 'ja (api:immediate-publication)'],
]);
$categoryId = $category?->legacy_id ?? 1;
$this->line(<<<CURL
# --- connektar-Flow (Alt-Vertrag, dev/betrieb/api-legacy-paritaet.md §1) ---
# 1. Firma anlegen
curl -s -i -X POST '{$host}/service/users/me/companies' \\
-H 'X-ApiKey: {$apiKey}' \\
-d 'company[name]=Neue Testfirma GmbH' \\
-d 'company[address]=Musterweg 2, 33602 Bielefeld' \\
-d 'company[country_id]=177' \\
-d 'company[email]=info@neue-testfirma.example'
# 2. Pressekontakt anlegen (COMPANY_ID aus Location-Header von Schritt 1)
curl -s -i -X POST '{$host}/service/users/me/companies/COMPANY_ID/contacts' \\
-H 'X-ApiKey: {$apiKey}' \\
-d 'contact[salutation_id]=1' \\
-d 'contact[first_name]=Erika' \\
-d 'contact[last_name]=Beispiel' \\
-d 'contact[email]=presse@neue-testfirma.example'
# 3. PM anlegen (= einreichen; mit Sofort-Publish geht sie direkt live)
curl -s -i -X POST '{$host}/service/pressreleases' \\
-H 'X-ApiKey: {$apiKey}' \\
-d 'press_release[company_id]={$company->id}' \\
-d 'press_release[category_id]={$categoryId}' \\
-d 'press_release[language]=de' \\
-d 'press_release[title]=Testmeldung ueber die Legacy-API' \\
-d 'press_release[text]=Dies ist eine Testmeldung aus der Legacy-API-Kompatibilitaetsschicht.' \\
-d 'press_release[contacts_list][]={$contact->id}'
# 4. Bild hochladen (PM_ID aus Location-Header von Schritt 3)
curl -s -i -X POST '{$host}/service/pressreleases/PM_ID/images' \\
-H 'X-ApiKey: {$apiKey}' \\
-F 'press_release_image[image]=@pressefoto.jpg' \\
-F 'press_release_image[copyright]=API Test GmbH'
# 5. Veroeffentlichungs-Kontrolle (404 = noch nicht veroeffentlicht)
curl -s '{$host}/service/pressreleases/PM_ID' -H 'X-ApiKey: {$apiKey}'
CURL);
$this->newLine();
$this->comment('Hinweis: Schalter "KI-Pruefung Legacy-API" auf /admin/press-releases steuert Sofort-Publish vs. KI-Review.');
return self::SUCCESS;
}
}

View file

@ -36,7 +36,20 @@ class SyncStripePlans extends Command
foreach (Plan::query()->active()->get() as $plan) {
if ($plan->stripe_product_id && $plan->stripe_price_id_monthly && $plan->stripe_price_id_yearly) {
$this->line("{$plan->slug}: vollständig verknüpft, übersprungen.");
// Preise sind unveränderlich, der Produktname nicht: Er wird
// bei jedem Lauf nachgezogen, damit Stripe-Belege den
// sprechenden Namen tragen (statt z. B. nur "Starter").
if ($dryRun) {
$this->line("[dry-run] {$plan->slug}: verknüpft — Produktname auf \"{$this->stripeProductName($plan)}\" aktualisieren.");
continue;
}
$stripe->products->update($plan->stripe_product_id, [
'name' => $this->stripeProductName($plan),
]);
$this->line("{$plan->slug}: verknüpft — Produktname aktualisiert.");
continue;
}
@ -56,7 +69,7 @@ class SyncStripePlans extends Command
if (! $productId) {
$product = $stripe->products->create([
'name' => $plan->name,
'name' => $this->stripeProductName($plan),
'metadata' => ['plan_slug' => $plan->slug],
]);
$productId = $product->id;
@ -114,7 +127,7 @@ class SyncStripePlans extends Command
$price = $stripe->prices->create([
'product' => $product->id,
'currency' => 'eur',
'currency' => strtolower((string) config('billing.default_currency')),
'unit_amount' => $amount,
'tax_behavior' => 'exclusive',
'metadata' => ['purpose' => 'single_pm'],
@ -124,6 +137,19 @@ class SyncStripePlans extends Command
$this->warn("Bitte in die .env eintragen: STRIPE_PRICE_SINGLE_PM={$price->id}");
}
/**
* Sprechender Produktname für Stripe-Belege: Auf Rechnung und
* Checkout-Seite erscheint sonst nur der nackte Tarifname.
*/
private function stripeProductName(Plan $plan): string
{
return sprintf(
'Tarif %s — %d Pressemitteilungen/Monat (pressekonto)',
$plan->name,
$plan->press_release_quota,
);
}
private function createPrice(object $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string
{
$price = $stripe->prices->create([

View file

@ -0,0 +1,55 @@
<?php
namespace App\Enums;
/**
* Ereignistypen des Aktivitäts-Verlaufs (Admin ▸ Reports ▸ Verlauf).
* Jeder Typ entspricht einem fachlichen Systemereignis; Details stecken
* in description/meta des ActivityEvent.
*/
enum ActivityType: string
{
case UserRegistered = 'user_registered';
case UserLoggedIn = 'user_logged_in';
case PressReleaseStatus = 'press_release_status';
case BookingPaid = 'booking_paid';
case CreditTopup = 'credit_topup';
case ContactRequest = 'contact_request';
case LegalRequest = 'legal_request';
case WithdrawalRequest = 'withdrawal_request';
case TerminationRequest = 'termination_request';
case NewsletterSubscription = 'newsletter_subscription';
case SubscriptionChange = 'subscription_change';
public function label(): string
{
return match ($this) {
self::UserRegistered => 'Registrierung',
self::UserLoggedIn => 'Anmeldung',
self::PressReleaseStatus => 'Pressemitteilung',
self::BookingPaid => 'Zahlung',
self::CreditTopup => 'Credit-Aufladung',
self::ContactRequest => 'Kontaktanfrage',
self::LegalRequest => 'Rechtsanfrage',
self::WithdrawalRequest => 'Widerruf',
self::TerminationRequest => 'Kündigung',
self::NewsletterSubscription => 'Newsletter',
self::SubscriptionChange => 'Abo',
};
}
/**
* Flux-Badge-Farbe in der Verlauf-Tabelle.
*/
public function badgeColor(): string
{
return match ($this) {
self::UserRegistered => 'green',
self::UserLoggedIn => 'zinc',
self::PressReleaseStatus => 'blue',
self::BookingPaid, self::CreditTopup => 'amber',
self::ContactRequest, self::NewsletterSubscription => 'cyan',
self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest, self::SubscriptionChange => 'red',
};
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Enums;
/**
* Selbst-Deklaration des KI-Einsatzes je PM-Bild (Art. 50 Abs. 4 KI-VO).
*
* Pflichtauswahl beim Bild-Upload (Decision-Update KI-Kennzeichnung,
* 08.07.2026). Bewusste Über-Erfüllung: gelabelt wird jedes deklarierte
* KI-Bild — auch stilisiert/abstrakt, nicht nur fotorealistische Deepfakes.
*/
enum AiImageDeclaration: string
{
case Original = 'original';
case AiGenerated = 'ai_generated';
case AiEdited = 'ai_edited';
/**
* Wortlaut der Auswahloption im Upload-Formular.
*/
public function label(): string
{
return match ($this) {
self::Original => __('Originalfoto'),
self::AiGenerated => __('KI-generiertes Bild'),
self::AiEdited => __('KI-bearbeitetes Bild'),
};
}
/**
* Öffentlicher Bildhinweis direkt am Bild (Badge) — null bei Originalfotos
* (keine Nicht-KI-Zertifizierung in Portalstimme). Kurzform „KI-generiert"
* statt Satz-Wortlaut (User-Entscheidung 10.07.2026).
*/
public function publicLabel(): ?string
{
return match ($this) {
self::Original => null,
self::AiGenerated => __('KI-generiert'),
self::AiEdited => __('KI-bearbeitet'),
};
}
/**
* Optionsliste für Radios/Selects: value => label.
*
* @return array<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View file

@ -0,0 +1,94 @@
<?php
namespace App\Enums;
/**
* Selbst-Deklaration des KI-Einsatzes beim PM-Text (Art. 50 Abs. 4 KI-VO).
*
* Pflichtauswahl im Submit-Flow (Decision-Update KI-Kennzeichnung, 08.07.2026;
* vereinfacht auf zwei Auswahloptionen, 10.07.2026): gelabelt wird allein nach
* dem deklarierten KI-Grad, nicht nach der Public-Interest-Frage — bewusste
* Über-Erfüllung. Das öffentliche Label spricht in Einreicher-Stimme, nie in
* Portalstimme.
*
* Alle vier Werte bleiben gültig (API-Vertrag v1 + Bestandsdaten); UI und
* Label-Logik fassen sie zu zwei Gruppen zusammen: None/Assisted = kein Label,
* MostlyAi/FullyAi = KI-Label.
*/
enum AiTextDeclaration: string
{
case None = 'none';
case Assisted = 'assisted';
case MostlyAi = 'mostly_ai';
case FullyAi = 'fully_ai';
/**
* Wortlaut der Auswahloption im Submit-Flow (zwei Gruppen).
*/
public function label(): string
{
return match ($this) {
self::None, self::Assisted => __('Ohne generative KI erstellt – oder KI nur als Formulierungshilfe, mit anschließender menschlicher Überarbeitung und Verantwortung.'),
self::MostlyAi, self::FullyAi => __('Ganz oder überwiegend mit generativer KI erstellt.'),
};
}
/**
* Öffentliches Label in Einreicher-Stimme — null, wenn kein Pflicht-Label
* fällig ist ("Ohne KI" wird bewusst nicht als Portal-Aussage zertifiziert,
* die Formulierungshilfe entspricht dem Entlastungsfall).
*/
public function publicLabel(): ?string
{
return match ($this) {
self::None, self::Assisted => null,
self::MostlyAi, self::FullyAi => __('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.'),
};
}
/**
* Kanonische Formular-Option der Gruppe — mappt Alt-/API-Werte
* (assisted, fully_ai) auf die zwei im Modal angebotenen Radios.
*/
public function formOption(): self
{
return match ($this) {
self::None, self::Assisted => self::None,
self::MostlyAi, self::FullyAi => self::MostlyAi,
};
}
/**
* Die zwei im Submit-Modal angebotenen Auswahloptionen.
*
* @return list<self>
*/
public static function formCases(): array
{
return [self::None, self::MostlyAi];
}
/**
* Optionsliste für Radios/Selects: value => label.
*
* @return array<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Enums;
/**
* Platzierungs-Stufe eines Boosts (Entscheidung 13.07.2026): der
* Startseiten-Boost (voller Preis) erscheint auf Startseite UND Branchenseite,
* der Kategorie-Boost (halber Preis) nur auf der Branchenseite. Beide Stufen
* haben ein eigenes Slot-Inventar je Ausgabe (config credits.boost_slots).
*/
enum BoostPlacement: string
{
case Home = 'home';
case Category = 'category';
public function label(): string
{
return match ($this) {
self::Home => 'Startseiten-Boost',
self::Category => 'Kategorie-Boost',
};
}
/**
* Wo die Platzierung sichtbar ist (für Add-on-UI und Kauf-Mails).
*/
public function placementDescription(): string
{
return match ($this) {
self::Home => 'Startseite und Branchenseite',
self::Category => 'Branchenseite der Rubrik',
};
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Enums;
enum ContactMessageStatus: string
{
case Open = 'open';
case InProgress = 'in_progress';
case Resolved = 'resolved';
case Rejected = 'rejected';
public function label(): string
{
return match ($this) {
self::Open => 'Offen',
self::InProgress => 'In Bearbeitung',
self::Resolved => 'Erledigt',
self::Rejected => 'Abgelehnt',
};
}
/**
* Offene Anliegen, die in der Kontakt-Queue Aufmerksamkeit brauchen
* (Nav-Zähler und Default-Filter der Admin-Ansicht).
*/
public function isOpen(): bool
{
return $this === self::Open || $this === self::InProgress;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Enums;
enum ContactMessageTopic: string
{
case Contact = 'contact';
case Advertising = 'advertising';
case BugReport = 'bug_report';
case PressReleaseReport = 'press_release_report';
case Feedback = 'feedback';
/**
* Interne (Admin-)Bezeichnung.
*/
public function label(): string
{
return match ($this) {
self::Contact => 'Kontaktaufnahme',
self::Advertising => 'Werbeanfrage',
self::BugReport => 'Fehlermeldung',
self::PressReleaseReport => 'PM-Meldung',
self::Feedback => 'Feedback',
};
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Enums;
/**
* Abbestellbare Benachrichtigungs-Mails (Opt-out je Typ).
*
* Umfasst bewusst nur Benachrichtigungen zum PM-Lebenszyklus — keine
* System-/Transaktionsmails (Passwort-Reset, Magic-Link, Kündigungs-/
* Widerrufsbestätigungen), die rechtlich bzw. funktional zwingend sind.
* Jeder Case entspricht genau einer Mailable-Klasse; vor dem Versand wird
* geprüft, ob der Empfänger den Typ abbestellt hat
* (User::wantsEmailNotification()).
*/
enum EmailNotificationType: string
{
case PressReleasePublished = 'press_release_published';
case PressReleaseRejected = 'press_release_rejected';
case PressReleaseDepublished = 'press_release_depublished';
public function label(): string
{
return match ($this) {
self::PressReleasePublished => 'Pressemitteilung veröffentlicht',
self::PressReleaseRejected => 'Pressemitteilung abgelehnt',
self::PressReleaseDepublished => 'Pressemitteilung depubliziert',
};
}
/**
* Beschreibung für die Präferenzen-Einstellung im Kundenbereich.
*/
public function settingsDescription(): string
{
return match ($this) {
self::PressReleasePublished => 'Benachrichtigung, sobald eine von Ihnen eingereichte Pressemitteilung geprüft und veröffentlicht wurde.',
self::PressReleaseRejected => 'Benachrichtigung, wenn eine eingereichte Pressemitteilung abgelehnt wurde — inklusive Ablehnungsgrund.',
self::PressReleaseDepublished => 'Bestätigung, wenn eine beauftragte Depublikation durchgeführt wurde.',
};
}
/**
* „Warum erhalten Sie diese E-Mail?"-Begründung im Mail-Footer.
*/
public function mailReason(): string
{
return match ($this) {
self::PressReleasePublished => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Veröffentlichung Ihrer Pressemitteilungen informiert.',
self::PressReleaseRejected => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Ablehnung Ihrer eingereichten Pressemitteilungen informiert.',
self::PressReleaseDepublished => 'Sie haben die Depublikation dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.',
};
}
/**
* Was der Empfänger mit dem Abmelde-Link abbestellt (Mail-Footer).
*/
public function unsubscribeLabel(): string
{
return match ($this) {
self::PressReleasePublished => 'Benachrichtigungen über das Veröffentlichen Ihrer Pressemitteilungen',
self::PressReleaseRejected => 'Benachrichtigungen über die Ablehnung Ihrer Pressemitteilungen',
self::PressReleaseDepublished => 'Bestätigungen über die Depublikation Ihrer Pressemitteilungen',
};
}
}

View file

@ -8,6 +8,21 @@ enum Portal: string
case Businessportal24 = 'businessportal24';
case Both = 'both';
/**
* Mappt ein Domain-Theme (aus config/domains.php, gesetzt vom
* ThemeServiceProvider) auf das zugehörige Portal. Admin-/Hub-Themes
* (portal, pressekonto, main, …) liefern null → kein Portal-Filter.
* Single Source of Truth für die Theme→Portal-Auflösung.
*/
public static function fromTheme(?string $theme): ?self
{
return match ($theme) {
'presseecho' => self::Presseecho,
'businessportal24' => self::Businessportal24,
default => null,
};
}
public function label(): string
{
return match ($this) {

View file

@ -24,4 +24,13 @@ enum PressReleaseClassification: string
self::Red => 'Rot',
};
}
public function badgeClass(): string
{
return match ($this) {
self::Green => 'ok',
self::Yellow => 'warn',
self::Red => 'err',
};
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
/**
* Art eines getrackten Ereignisses an einer Pressemitteilung.
*
* View-Events entstehen serverseitig beim Aufruf der öffentlichen
* Detailseite (zusätzlich zum Legacy-Zähler `press_releases.hits`),
* Share-Events beim Klick auf die Teilen-Buttons der Detailseite.
*/
enum PressReleaseEventType: string
{
case View = 'view';
case Share = 'share';
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Enums;
/**
* Teilen-Kanäle der öffentlichen PM-Detailseite (Share-Buttons).
*/
enum PressReleaseShareChannel: string
{
case LinkedIn = 'linkedin';
case X = 'x';
case Email = 'email';
case CopyLink = 'copy';
public function label(): string
{
return match ($this) {
self::LinkedIn => 'LinkedIn',
self::X => 'X',
self::Email => __('E-Mail'),
self::CopyLink => __('Link kopiert'),
};
}
}

View file

@ -20,4 +20,19 @@ enum PressReleaseStatus: string
self::Archived => 'Archiviert',
};
}
/**
* CSS-Badge-Modifier für die Status-Darstellung (siehe `.badge`-Klassen im
* Hub-CSS). Neutrale Zustände (Entwurf/Archiviert) werden bewusst grau
* abgesetzt (`muted`), aktive Zustände farblich hervorgehoben.
*/
public function badgeClass(): string
{
return match ($this) {
self::Published => 'ok',
self::Review => 'warn',
self::Rejected => 'err',
self::Draft, self::Archived => 'muted',
};
}
}

View file

@ -8,6 +8,7 @@ enum SinglePurchaseType: string
case ExtraPm = 'extra_pm';
case Boost = 'boost';
case ProofPdf = 'proof_pdf';
case Depublication = 'depublication';
public function label(): string
{
@ -16,6 +17,23 @@ enum SinglePurchaseType: string
self::ExtraPm => 'Extra-PM',
self::Boost => 'Boost / Platzierung',
self::ProofPdf => 'Veröffentlichungsnachweis (PDF)',
self::Depublication => 'Depublikation (Entfernung aus der öffentlichen Ansicht)',
};
}
/**
* Kompaktes Label für Listenzeilen, in denen der Kauftyp dem PM-Titel
* vorangestellt wird (z. B. „Depublikation: <Titel>" im Verlauf der
* Buchungs-Seite).
*/
public function shortLabel(): string
{
return match ($this) {
self::SinglePm => 'Einzel-Pressemitteilung',
self::ExtraPm => 'Extra-PM',
self::Boost => 'Boost',
self::ProofPdf => 'Veröffentlichungsnachweis',
self::Depublication => 'Depublikation',
};
}

View file

@ -2,11 +2,14 @@
namespace App\Exceptions;
use App\Enums\BoostPlacement;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* Wird geworfen, wenn eine PM nicht geboostet werden darf — nur
* veröffentlichte, grün klassifizierte Meldungen sind boostbar (§2.2).
* veröffentlichte, grün klassifizierte Meldungen sind boostbar (§2.2) und
* je Ausgabe gibt es nur begrenzte Slots (Entscheidung 13.07.2026).
*/
class BoostNotAllowedException extends RuntimeException
{
@ -14,4 +17,11 @@ class BoostNotAllowedException extends RuntimeException
{
return new self('Nur veröffentlichte, grün klassifizierte Pressemitteilungen sind boostbar.');
}
public static function soldOut(BoostPlacement $placement, ?Carbon $freeAt): self
{
return new self($freeAt
? "{$placement->label()} derzeit ausgebucht — der nächste Platz wird am {$freeAt->format('d.m.Y')} frei."
: "{$placement->label()} derzeit ausgebucht.");
}
}

View file

@ -34,13 +34,39 @@ class ThemeHelper
}
/**
* Get the favicon path based on the current theme
* Map the current theme to its icon folder under public/icons.
*
* Das Portal-/Admin-Theme läuft auf pressekonto.test und nutzt daher
* den pressekonto-Iconsatz.
*/
public static function getFaviconPath(): string
public static function getIconFolder(): string
{
$theme = config('app.theme', 'portal');
return "img/favicons/{$theme}-favicon.ico";
$folderMap = [
'portal' => 'pressekonto',
'pressekonto' => 'pressekonto',
'presseecho' => 'presseecho',
'businessportal24' => 'businessportal24',
];
return $folderMap[$theme] ?? 'pressekonto';
}
/**
* Get the favicon (SVG) path based on the current theme.
*/
public static function getFaviconPath(): string
{
return 'icons/'.self::getIconFolder().'/favicon.svg';
}
/**
* Get the larger app/touch icon (SVG) path based on the current theme.
*/
public static function getAppIconPath(): string
{
return 'icons/'.self::getIconFolder().'/icon.svg';
}
/**

View file

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\CompanyType;
use App\Http\Controllers\Controller;
use App\Models\Company;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* Alt-Vertrag POST /service/users/me/companies (meistgenutzter Legacy-Call,
* 5.935x in den Prod-Logs): Felder verschachtelt unter company[...],
* Response 201 + Location + {}. Paritäts-Doku §1/§4 S1.
*/
class LegacyCompanyController extends Controller
{
use RespondsLikeLegacyApi;
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
try {
$validated = $request->validate([
'company' => ['required', 'array'],
'company.name' => ['required', 'string', 'max:255'],
'company.address' => ['nullable', 'string', 'max:1000'],
'company.country_id' => ['nullable', 'integer'],
'company.phone' => ['nullable', 'string', 'max:100'],
'company.fax' => ['nullable', 'string', 'max:100'],
'company.email' => ['nullable', 'email', 'max:255'],
'company.website' => ['nullable', 'string', 'max:255'],
])['company'];
} catch (ValidationException $exception) {
return $this->legacyValidationError($exception);
}
$user = $request->user();
$company = new Company([
'portal' => $user->portal?->value,
'type' => CompanyType::Company->value,
'name' => $validated['name'],
'address' => $validated['address'] ?? null,
'country_code' => $this->legacyCountryCode($validated['country_id'] ?? null),
'phone' => $validated['phone'] ?? null,
'fax' => $validated['fax'] ?? null,
'email' => $validated['email'] ?? null,
'website' => $validated['website'] ?? null,
// Alt-Verhalten: per API angelegte Firmen waren sofort nutzbar.
'is_active' => true,
]);
$company->owner_user_id = $user->id;
$company->slug = $company->generateUniqueSlug($company->name);
$company->save();
$company->users()->attach($user->id, ['role' => 'owner']);
return $this->legacyCreated(url("/service/users/me/companies/{$company->id}"));
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Company;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* Alt-Vertrag POST /service/users/me/companies/{id}/contacts:
* Felder unter contact[...], salutation_id 1/2 → mr/mrs (wie ContactImporter),
* nur eigene Firmen. Paritäts-Doku §1/§4 S1.
*/
class LegacyContactController extends Controller
{
use RespondsLikeLegacyApi;
public function store(Request $request, int $company): JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
$company = Company::withoutGlobalScopes()
->whereKey($company)
->accessibleBy($request->user())
->first();
if (! $company) {
return $this->legacyError(403, ['You may only access companies which are related to your user-account']);
}
try {
$validated = $request->validate([
'contact' => ['required', 'array'],
'contact.salutation_id' => ['nullable', 'integer'],
'contact.title' => ['nullable', 'string', 'max:100'],
'contact.first_name' => ['required', 'string', 'max:255'],
'contact.last_name' => ['required', 'string', 'max:255'],
'contact.responsibility' => ['nullable', 'string', 'max:255'],
'contact.phone' => ['nullable', 'string', 'max:100'],
'contact.fax' => ['nullable', 'string', 'max:100'],
'contact.email' => ['nullable', 'email', 'max:255'],
])['contact'];
} catch (ValidationException $exception) {
return $this->legacyValidationError($exception);
}
$contact = $company->contacts()->create([
'portal' => $company->portal?->value,
'salutation_key' => match ((int) ($validated['salutation_id'] ?? 0)) {
1 => 'mr',
2 => 'mrs',
default => null,
},
'title' => $validated['title'] ?? null,
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'responsibility' => $validated['responsibility'] ?? null,
'phone' => $validated['phone'] ?? null,
'fax' => $validated['fax'] ?? null,
'email' => $validated['email'] ?? null,
]);
return $this->legacyCreated(url("/service/users/me/companies/{$company->id}/contacts/{$contact->id}"));
}
}

View file

@ -0,0 +1,200 @@
<?php
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiTextDeclaration;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Models\AppSetting;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use function Illuminate\Support\defer;
/**
* Alt-Vertrag POST/GET /service/pressreleases (Paritäts-Doku §1/§4):
*
* POST: Felder unter press_release[...]; Anlegen IST Einreichen. Kunden mit
* Permission api:immediate-publication (connektar) veröffentlichen sofort —
* außer der Admin-Schalter „KI-Prüfung Legacy-API" ist an, dann Status review
* und die KI routet (Gelb/Grün auto-publish, Rot Ablehnung). KI-Deklaration
* defaulted auf „ohne generative KI" (Entscheidung 10.07.).
*
* GET {id}: nur published (404 sonst — Alt-Clients pollen darüber den
* Veröffentlichungsstatus), zählt Hits, flaches Alt-Objekt.
*/
class LegacyPressReleaseController extends Controller
{
use RespondsLikeLegacyApi;
public const SETTING_AI_REVIEW = 'legacy_api_ai_review';
public function store(
Request $request,
PressReleaseService $service,
PressReleaseHtmlSanitizer $sanitizer,
): JsonResponse {
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
try {
$validated = $request->validate([
'press_release' => ['required', 'array'],
'press_release.company_id' => ['required', 'integer'],
'press_release.category_id' => ['required', 'integer'],
'press_release.language' => ['nullable', 'string', 'max:5'],
'press_release.title' => ['required', 'string', 'max:255'],
'press_release.text' => ['required', 'string'],
'press_release.backlink_url' => ['nullable', 'url', 'max:255'],
'press_release.keywords' => ['nullable', 'string', 'max:255'],
'press_release.contacts_list' => ['nullable', 'array'],
'press_release.contacts_list.*' => ['integer'],
])['press_release'];
} catch (ValidationException $exception) {
return $this->legacyValidationError($exception);
}
$user = $request->user();
$company = Company::withoutGlobalScopes()
->whereKey((int) $validated['company_id'])
->accessibleBy($user)
->first();
if (! $company) {
return $this->legacyError(403, ['You may only access companies which are related to your user-account']);
}
// Alt-Clients senden Legacy-Kategorie-IDs; nach der Migration sind
// id und legacy_id identisch — legacy_id zuerst, id als Fallback.
$category = Category::withoutGlobalScopes()->where('legacy_id', (int) $validated['category_id'])->first()
?? Category::withoutGlobalScopes()->find((int) $validated['category_id']);
if (! $category) {
return $this->legacyError(422, ['Unknown category.']);
}
$language = strtolower(substr((string) ($validated['language'] ?? 'de'), 0, 2)) ?: 'de';
$text = $sanitizer->isHtml($validated['text'])
? $sanitizer->clean($validated['text'])
: $validated['text'];
$pressRelease = new PressRelease([
'company_id' => $company->id,
'category_id' => $category->id,
'language' => $language,
'title' => $validated['title'],
'text' => $text,
'backlink_url' => $validated['backlink_url'] ?? null,
'keywords' => $validated['keywords'] ?? null,
'status' => PressReleaseStatus::Draft->value,
// Kompatibilitäts-Default (Entscheidung 10.07.): Alt-Clients
// kennen die KI-Kennzeichnung nicht.
'ai_text_declaration' => AiTextDeclaration::None->value,
]);
$pressRelease->uuid = (string) Str::uuid();
$pressRelease->user_id = $user->id;
$pressRelease->portal = $company->portal?->value;
$pressRelease->ai_declaration_confirmed_at = now();
$pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [
'portal' => $pressRelease->portal,
'language' => $language,
]);
$pressRelease->saveWithUniqueSlugRetry($pressRelease->title, [
'portal' => $pressRelease->portal,
'language' => $language,
]);
if (! empty($validated['contacts_list'])) {
$ownContactIds = $company->contacts()->whereIn('contacts.id', $validated['contacts_list'])->pluck('contacts.id');
$pressRelease->contacts()->sync($ownContactIds);
}
// Anlegen ist Einreichen (Alt-Verhalten): Gates + KI-Jobs laufen
// über denselben Funnel wie Panel/v1.
try {
$service->submitForReview($pressRelease, 'api');
} catch (BookingRequiredException|QuotaExceededException $exception) {
// Gates greifen vor jeder Statusänderung -> Draft rückstandslos
// entfernen (Alt-Verhalten: ungültiger Request legt nichts an).
$pressRelease->delete();
return $this->legacyError(422, [$exception->getMessage()]);
} catch (BlacklistViolationException $exception) {
// PM ist bereits automatisch abgelehnt + protokolliert -- stehen
// lassen (wie v1), dem Client den Alt-Fehler melden.
return $this->legacyError(422, [$exception->getMessage()]);
}
$aiReviewEnabled = AppSetting::getBool(self::SETTING_AI_REVIEW);
if (! $aiReviewEnabled && $user->can('api:immediate-publication')) {
try {
$service->publish($pressRelease, 'api');
} catch (BlacklistViolationException $exception) {
return $this->legacyError(422, [$exception->getMessage()]);
}
}
return $this->legacyCreated(url("/service/pressreleases/{$pressRelease->id}"));
}
public function show(Request $request, int $pressRelease): JsonResponse
{
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease)
->with(['company'])
->first();
if (
! $pressRelease
|| $pressRelease->status !== PressReleaseStatus::Published
|| ($pressRelease->company && ! $pressRelease->company->is_active)
) {
return $this->legacyError(404, ['Not found.']);
}
// Hit-Zählung wie im Altsystem (dort auch für API-Abrufe), aber erst
// nach der Antwort und atomar, damit parallele Polls keine Hits verlieren.
$pressReleaseId = $pressRelease->getKey();
defer(static fn () => PressRelease::recordDeferredView($pressReleaseId));
$spokeUrl = $this->spokeBaseUrl($pressRelease->portal?->value);
$payload = [
'id' => $pressRelease->id,
'title' => $pressRelease->title,
'text' => $pressRelease->text,
'teaser_end' => $pressRelease->teaser_end,
'language' => $pressRelease->language,
'category_id' => $pressRelease->category_id,
'view_url' => "{$spokeUrl}/de/press-release/{$pressRelease->slug}",
];
if ($pressRelease->company) {
$payload['company'] = [
'viewUrl' => "{$spokeUrl}/de/newsroom/{$pressRelease->company->slug}",
'name' => $pressRelease->company->name,
];
}
return response()->json($payload);
}
private function spokeBaseUrl(?string $portal): string
{
$key = in_array($portal, ['presseecho', 'businessportal24'], true) ? $portal : 'businessportal24';
return rtrim((string) config("domains.domains.{$key}.url", config('app.url')), '/');
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiImageDeclaration;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Services\Image\ImageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use RuntimeException;
/**
* Alt-Vertrag POST /service/pressreleases/{id}/images: Multipart mit Feldern
* unter press_release_image[...], nur Eigentümer (403-Wortlaut wie alt),
* Response 201 + Location + {}. KI-Kennzeichnung defaulted auf Originalfoto.
*/
class LegacyPressReleaseImageController extends Controller
{
public function __construct(private readonly ImageService $imageService) {}
use RespondsLikeLegacyApi;
public function store(Request $request, int $pressRelease): JsonResponse
{
abort_unless($request->user()->tokenCan('press-release-images:write'), 403);
$pressRelease = PressRelease::withoutGlobalScopes()->find($pressRelease);
if (! $pressRelease) {
return $this->legacyError(404, [sprintf('Object press_release does not exist (%s).', $request->route('pressRelease'))]);
}
if ($pressRelease->user_id !== $request->user()->id) {
return $this->legacyError(403, ['You are not the creator of this press release']);
}
try {
$validated = $request->validate([
'press_release_image' => ['required', 'array'],
'press_release_image.image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:8192'],
'press_release_image.title' => ['nullable', 'string', 'max:120'],
'press_release_image.description' => ['nullable', 'string', 'max:500'],
'press_release_image.copyright' => ['nullable', 'string', 'max:255'],
])['press_release_image'];
} catch (ValidationException $exception) {
return $this->legacyValidationError($exception);
}
try {
$stored = $this->imageService->storePressReleaseImage(
$request->file('press_release_image.image'),
$pressRelease->id,
);
} catch (RuntimeException $exception) {
return $this->legacyError(422, [$exception->getMessage()]);
}
$image = $pressRelease->images()->create([
'disk' => $stored['disk'],
'path' => $stored['path'],
'variants' => $stored['variants'],
'ai_declaration' => AiImageDeclaration::Original->value,
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'copyright' => $validated['copyright'] ?? null,
'is_preview' => false,
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
'width' => $stored['width'],
'height' => $stored['height'],
'mime' => $stored['mime'],
]);
return $this->legacyCreated(url("/service/pressreleases/{$pressRelease->id}/images/{$image->id}"));
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\Legacy;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
/**
* Response-Konventionen der Alt-API (Symfony, tnHttpError-Templates):
* Erfolg beim Anlegen = 201 + Location-Header + Body {};
* Fehler = {"global_errors":[…]} bzw. 422 zusätzlich mit "field_errors".
* Vertrag dokumentiert in dev/betrieb/api-legacy-paritaet.md §1.
*/
trait RespondsLikeLegacyApi
{
protected function legacyCreated(string $locationUrl): JsonResponse
{
return response()->json((object) [], 201)->header('Location', $locationUrl);
}
protected function legacyError(int $status, array $globalErrors): JsonResponse
{
return response()->json(['global_errors' => $globalErrors], $status);
}
protected function legacyValidationError(ValidationException $exception): JsonResponse
{
return response()->json([
'global_errors' => [],
'field_errors' => $exception->errors(),
], 422);
}
/**
* Legacy-Land (country.id der Altportale) → ISO-Code des neuen Systems.
* Häufigste IDs aus dem Prod-Snapshot; unbekannte → null (Feld leer).
*/
protected function legacyCountryCode(mixed $legacyCountryId): ?string
{
return match ((int) $legacyCountryId) {
177 => 'DE',
80 => 'AT',
196 => 'CH',
229 => 'US',
30 => 'GB',
165 => 'FR',
78 => 'IT',
2 => 'NL',
47 => 'ES',
108 => 'CY',
115 => 'LI',
117 => 'LU',
15 => 'AU',
89 => 'CA',
default => null,
};
}
}

View file

@ -15,10 +15,7 @@ class CompanyController extends Controller
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));
})
->accessibleBy($request->user())
->orderBy('name')
->paginate(min((int) $request->query('per_page', 25), 100));
@ -36,10 +33,7 @@ class CompanyController extends Controller
$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));
})
->accessibleBy($request->user())
->first();
abort_unless($company !== null, 403);

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
use App\Models\NewsletterSubscription;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class NewsletterSubscriptionController extends Controller
@ -14,23 +15,57 @@ class NewsletterSubscriptionController extends Controller
{
$validated = $request->validated();
$subscription = NewsletterSubscription::withoutGlobalScopes()->updateOrCreate(
[
'portal' => $validated['portal'],
'email' => mb_strtolower($validated['email']),
],
[
$email = mb_strtolower($validated['email']);
$subscription = DB::transaction(function () use ($request, $validated, $email): NewsletterSubscription {
$subscription = NewsletterSubscription::withoutGlobalScopes()
->where('portal', $validated['portal'])
->where('email', $email)
->lockForUpdate()
->first();
// Nur ein fremder, tatsächlich gesetzter Eigentümer blockiert (409).
// Verwaiste Datensätze (user_id NULL nach Konto-Löschung/Legacy-Import)
// dürfen neu gebunden werden — sonst wäre die portal+email-Kombination
// dauerhaft nicht mehr abonnierbar.
if ($subscription !== null && $subscription->user_id !== null && $subscription->user_id !== $request->user()->id) {
abort(409, 'This email address is already subscribed.');
}
if ($subscription === null) {
return NewsletterSubscription::withoutGlobalScopes()->create([
'portal' => $validated['portal'],
'email' => $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,
]);
}
$attributes = [
'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,
],
);
];
if (! $subscription->is_confirmed) {
$attributes['confirmation_token'] = Str::random(32);
}
$subscription->update($attributes);
return $subscription;
});
return response()->json([
'message' => 'Newsletter subscription created.',

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\V1;
use App\Enums\AiTextDeclaration;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
@ -9,8 +10,10 @@ use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
use App\Http\Resources\PressReleaseResource;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Http\JsonResponse;
@ -18,6 +21,7 @@ use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class PressReleaseController extends Controller
{
@ -27,7 +31,7 @@ class PressReleaseController extends Controller
$pressReleases = PressRelease::withoutGlobalScopes()
->where('user_id', $request->user()->id)
->with(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
->with($this->apiResourceRelations())
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
->latest()
->paginate(min((int) $request->query('per_page', 25), 100));
@ -37,28 +41,40 @@ class PressReleaseController extends Controller
public function store(StorePressReleaseRequest $request): JsonResponse
{
$validated = $request->validated();
$validated = $this->sanitizeHtmlText($request->validated());
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
abort_unless($company !== null, 403);
$pressRelease = PressRelease::withoutGlobalScopes()->create([
$pressRelease = new PressRelease([
...$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'],
),
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
// `review` erfolgt ausschließlich über die explizite submit-Route,
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
'status' => PressReleaseStatus::Draft->value,
// Legacy-/service-Clients kennen die KI-Kennzeichnung (Art. 50
// KI-VO) nicht — Entscheidung 10.07.: fehlende Angabe gilt als
// „ohne generative KI erstellt"; das API-Kunden-Mailing (R6.4)
// weist darauf hin, dass die weitere Nutzung diese Erklärung
// darstellt. v1-Clients deklarieren explizit beim Submit.
...($this->isLegacyServiceRequest($request) ? [
'ai_text_declaration' => AiTextDeclaration::None->value,
'ai_declaration_confirmed_at' => now(),
] : []),
]);
$pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [
'portal' => $company->portal->value,
'language' => $validated['language'],
]);
$pressRelease->saveWithUniqueSlugRetry($validated['title'], [
'portal' => $company->portal->value,
'language' => $validated['language'],
]);
return PressReleaseResource::make(
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
$pressRelease->load($this->apiResourceRelations())
)->response()->setStatusCode(201);
}
@ -69,7 +85,7 @@ class PressReleaseController extends Controller
abort_unless($pressRelease !== null, 403);
return PressReleaseResource::make(
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
$pressRelease->load($this->apiResourceRelations())
);
}
@ -84,7 +100,7 @@ class PressReleaseController extends Controller
], 409);
}
$validated = $request->validated();
$validated = $this->sanitizeHtmlText($request->validated());
$company = isset($validated['company_id'])
? $this->findOwnedCompany((int) $validated['company_id'], $request)
: $this->findOwnedCompany((int) $pressRelease->company_id, $request);
@ -104,10 +120,17 @@ class PressReleaseController extends Controller
|| array_key_exists('language', $validated)
|| array_key_exists('company_id', $validated)
) {
$pressRelease->slug = $this->uniqueSlug(Str::slug($title), $portal, $language, $pressRelease->id);
// Selbst-Ausschluss übernimmt der Trait automatisch ($self->exists).
$pressRelease->slug = $pressRelease->generateUniqueSlug($title, [
'portal' => $portal,
'language' => $language,
]);
}
$pressRelease->save();
$pressRelease->saveWithUniqueSlugRetry($title, [
'portal' => $portal,
'language' => $language,
]);
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen.
if ($pressRelease->wasChanged(['title', 'text'])) {
@ -117,7 +140,7 @@ class PressReleaseController extends Controller
}
return PressReleaseResource::make(
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
$pressRelease->load($this->apiResourceRelations())
);
}
@ -132,6 +155,16 @@ class PressReleaseController extends Controller
public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
// KI-Kennzeichnungspflicht (Art. 50 KI-VO) auch via API: der
// Submit-Call trägt den deklarierten KI-Grad des Textes und gilt
// dokumentiert als verbindliche Bestätigung der Deklaration sowie
// Kenntnisnahme der dauerhaften Archivierung (AGB § 13) —
// Entscheidung 10.07.2026.
$validated = $request->validate([
'ai_text_declaration' => ['required', Rule::enum(AiTextDeclaration::class)],
]);
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
abort_unless($pressRelease !== null, 403);
@ -141,8 +174,13 @@ class PressReleaseController extends Controller
], 409);
}
$pressRelease->update([
'ai_text_declaration' => $validated['ai_text_declaration'],
'ai_declaration_confirmed_at' => now(),
]);
try {
$service->submitForReview($pressRelease);
$service->submitForReview($pressRelease, 'api');
} catch (BookingRequiredException $exception) {
return response()->json([
'message' => $exception->getMessage(),
@ -151,6 +189,10 @@ class PressReleaseController extends Controller
return response()->json([
'message' => $exception->getMessage(),
], 422);
} catch (AiDeclarationRequiredException $exception) {
return response()->json([
'message' => $exception->getMessage(),
], 422);
} catch (BlacklistViolationException $exception) {
return response()->json([
'message' => $exception->getMessage(),
@ -158,7 +200,7 @@ class PressReleaseController extends Controller
}
return PressReleaseResource::make(
$pressRelease->fresh()->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
$pressRelease->fresh()->load($this->apiResourceRelations())
);
}
@ -183,39 +225,57 @@ class PressReleaseController extends Controller
{
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));
})
->accessibleBy($request->user())
->first();
}
/**
* Relationen für die API-Resource-Ausgabe. Zentral, damit Index, Show,
* Create, Update und Reclassify denselben Satz laden.
*
* @return list<string>
*/
private function apiResourceRelations(): array
{
return ['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'];
}
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
{
return PressRelease::withoutGlobalScopes()
->whereKey($pressReleaseId)
->where('user_id', $request->user()->id)
->ownedByApiUser($request->user())
->first();
}
private function uniqueSlug(string $baseSlug, string $portal, string $language, ?int $excludeId = null): string
/**
* Kam der Call über die Legacy-Aliasse (/service/…, routes/legacy-api.php)?
* Dort gelten Kompatibilitäts-Defaults für Felder, die die Altportale
* nicht kannten (KI-Kennzeichnung).
*/
private function isLegacyServiceRequest(Request $request): bool
{
$slug = $baseSlug !== '' ? $baseSlug : Str::random(8);
$candidate = $slug;
$suffix = 2;
return str_starts_with((string) $request->route()?->getName(), 'legacy-service.');
}
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++;
/**
* Säubert einen per API übergebenen HTML-Text vor der Persistenz — analog
* zum Web-Formular, das immer über den Sanitizer speichert. Nur HTML wird
* durch HTMLPurifier gejagt; Legacy-Plaintext bleibt unverändert (wird beim
* Rendern ohnehin escaped), damit `<`/`&` in Klartext-Einreichungen nicht
* zerstört werden.
*
* @param array<string, mixed> $validated
* @return array<string, mixed>
*/
private function sanitizeHtmlText(array $validated): array
{
$sanitizer = app(PressReleaseHtmlSanitizer::class);
if (array_key_exists('text', $validated) && $sanitizer->isHtml($validated['text'])) {
$validated['text'] = $sanitizer->clean($validated['text']);
}
return $candidate;
return $validated;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\V1;
use App\Enums\AiImageDeclaration;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
@ -60,9 +61,13 @@ class PressReleaseImageController extends Controller
}
$image = $pressRelease->images()->create([
'disk' => 'public',
'disk' => $stored['disk'],
'path' => $stored['path'],
'variants' => $stored['variants'],
// v1 verlangt die KI-Kennzeichnung je Bild (FormRequest);
// Legacy-/service-Calls ohne Feld gelten als Originalfoto
// (Kompatibilitäts-Default, Entscheidung 10.07.2026).
'ai_declaration' => $validated['ai_declaration'] ?? AiImageDeclaration::Original->value,
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'copyright' => $validated['copyright'] ?? null,
@ -106,7 +111,7 @@ class PressReleaseImageController extends Controller
{
return PressRelease::withoutGlobalScopes()
->whereKey($pressReleaseId)
->where('user_id', $request->user()->id)
->ownedByApiUser($request->user())
->first();
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ConfirmEmailChangeController extends Controller
{
/**
* Übernimmt eine ausstehende E-Mail-Änderung, nachdem der Kunde den
* signierten Link aus der Mail an die NEUE Adresse geklickt hat.
* Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern).
*/
public function __invoke(Request $request, User $user, string $hash): RedirectResponse
{
abort_unless($request->user()->is($user), 403);
if (! $user->pending_email) {
return redirect()->route('me.security')
->with('security-status', __('Es liegt keine ausstehende E-Mail-Änderung vor. Möglicherweise wurde der Link bereits verwendet.'));
}
abort_unless(hash_equals(sha1($user->pending_email), $hash), 403);
// Zwischenzeitlich vergeben? Dann Änderung verwerfen statt Konflikt anlegen.
if (User::query()->where('email', $user->pending_email)->whereKeyNot($user->id)->exists()) {
$user->forceFill(['pending_email' => null])->save();
return redirect()->route('me.security')
->with('security-error', __('Die neue E-Mail-Adresse wird inzwischen von einem anderen Konto verwendet. Die Änderung wurde verworfen.'));
}
$user->forceFill([
'email' => $user->pending_email,
'email_verified_at' => now(),
'pending_email' => null,
])->save();
return redirect()->route('me.security')
->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.'));
}
}

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Services\Auth\SocialAuthService;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
@ -45,6 +46,7 @@ class GoogleController extends Controller
]);
}
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'google');
Auth::login($user, remember: true);
$request->session()->regenerate();

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Models\MagicLink;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -45,18 +46,36 @@ class MagicLinkConsumeController extends Controller
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
}
$magicLink->user->update([
$user = $magicLink->user;
// 2FA aktiv: nicht direkt einloggen, sondern – wie der Passwort-Login –
// in Fortifys Challenge übergeben. Der Magic-Link (Besitz des Postfachs)
// ersetzt nur das Passwort, nicht den zweiten Faktor. Der Link ist bereits
// atomar verbraucht; bricht der Nutzer die Challenge ab, fordert er einen
// neuen an.
if ($user->hasEnabledTwoFactorAuthentication()) {
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => false,
]);
$request->session()->regenerate();
return redirect()->route('two-factor.challenge');
}
$user->update([
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
]);
Auth::guard('web')->login($magicLink->user);
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'magic-link');
Auth::guard('web')->login($user);
$request->session()->regenerate();
// Rollensicherer Redirect ohne intended(): eine als Gast besuchte
// Admin-URL (z. B. /dashboard) darf einen Customer nach dem
// Magic-Link-Login nicht in den 403-Admin-Bereich schicken.
$user = $magicLink->user;
$home = $user->canAccessAdmin()
? route('dashboard', absolute: false)

View file

@ -2,15 +2,20 @@
namespace App\Http\Controllers;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Models\BookingConfirmation;
use App\Models\Plan;
use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Services\Billing\CreditTopupService;
use App\Services\Billing\StripeCheckoutService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Exceptions\IncompletePayment;
/**
* Einstieg in die Stripe-Checkouts (Phase 9E): Tarif-Abo und Einzel-PM.
@ -33,8 +38,12 @@ class CheckoutController extends Controller
return $this->backToProfile();
}
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
return $redirect;
}
if ($user->subscribed()) {
return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.'));
return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Nutzen Sie den Tarifwechsel direkt an der gewünschten Tarif-Karte.'));
}
$priceId = $interval === 'yearly'
@ -48,12 +57,84 @@ class CheckoutController extends Controller
return $this->checkout->forSubscription($user, $plan, $interval);
}
/**
* Tarifwechsel eines aktiven Abos (POST aus dem Buchungs-Modal) — kein
* neuer Stripe-Checkout: Stripe stellt das bestehende Abo sofort um und
* verrechnet anteilig (swapAndInvoice). Eine vorgemerkte Kündigung wird
* durch den Wechsel aufgehoben.
*/
public function swap(Request $request, string $planSlug, string $interval): RedirectResponse
{
$plan = Plan::query()->active()->where('slug', $planSlug)->firstOrFail();
$user = $request->user();
if (! $user->hasCompleteBillingAddress()) {
return $this->backToProfile();
}
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
return $redirect;
}
$subscription = $user->subscription();
if (! $subscription || ! $subscription->valid()) {
return $this->backToBookings(__('Es besteht kein aktives Abo — bitte buchen Sie den gewünschten Tarif direkt.'));
}
$priceId = $interval === 'yearly'
? $plan->stripe_price_id_yearly
: $plan->stripe_price_id_monthly;
if (! $priceId) {
return $this->backToBookings(__('Dieser Tarif ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
}
if ($subscription->stripe_price === $priceId) {
return $this->backToBookings(__('Dieser Tarif ist bereits Ihr aktueller Tarif.'));
}
try {
// Downgrade-Vorkehrung (QA-Befund 07.07.): Ein günstigerer Tarif
// wird erst zum Ende der bezahlten Periode wirksam — es entsteht
// nie eine Gutschrift oder ein negativer Rechnungsbetrag. Nur
// Upgrades werden sofort umgestellt und anteilig nachberechnet.
if ($this->checkout->isDowngrade($user, $plan, $interval)) {
$this->checkout->scheduleDowngrade($user, $plan, $interval);
return redirect()->route('me.bookings.index', ['checkout' => 'wechsel-geplant']);
}
$this->checkout->swapSubscription($user, $plan, $interval);
} catch (IncompletePayment $exception) {
return redirect()->route('cashier.payment', [
$exception->payment->id,
'redirect' => route('me.bookings.index', ['checkout' => 'wechsel']),
]);
} catch (\Throwable $exception) {
Log::error('Tarifwechsel fehlgeschlagen.', [
'user_id' => $user->id,
'plan' => $plan->slug,
'interval' => $interval,
'error' => $exception->getMessage(),
]);
return $this->backToBookings(__('Der Tarifwechsel konnte nicht durchgeführt werden. Bitte versuchen Sie es erneut oder wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')]));
}
return redirect()->route('me.bookings.index', ['checkout' => 'wechsel']);
}
public function singlePm(Request $request): Checkout|RedirectResponse
{
if (! $request->user()->hasCompleteBillingAddress()) {
return $this->backToProfile();
}
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
return $redirect;
}
if (! config('billing.single_pm_stripe_price_id')) {
return $this->backToBookings(__('Die Einzel-Pressemitteilung ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
}
@ -63,7 +144,7 @@ class CheckoutController extends Controller
'type' => SinglePurchaseType::SinglePm->value,
'status' => SinglePurchaseStatus::Pending->value,
'price_cents' => (int) config('billing.single_pm_price_cents'),
'currency' => 'EUR',
'currency' => config('billing.default_currency'),
]);
return $this->checkout->forSinglePurchase($request->user(), $purchase);
@ -80,6 +161,10 @@ class CheckoutController extends Controller
return $this->backToProfile();
}
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
return $redirect;
}
if (! $topups->findPack($pack)) {
return $this->backToBookings(__('Dieses Credit-Paket ist nicht verfügbar.'));
}
@ -89,6 +174,65 @@ class CheckoutController extends Controller
return $this->checkout->forCreditTopup($request->user(), $topup);
}
/**
* Depublikation einer eigenen, veröffentlichten PM (T12, Meldeverfahren
* Weg 3). Config-gated: ohne konfigurierten Preis ist der Weg
* deaktiviert. Die Entfernung selbst übernimmt der Webhook nach
* Zahlungseingang (Tombstone bleibt öffentlich sichtbar).
*/
public function depublication(Request $request, int $pressReleaseId): Checkout|RedirectResponse
{
$user = $request->user();
$priceCents = (int) config('billing.depublication_price_cents');
if ($priceCents <= 0) {
return $this->backToBookings(__('Die Depublikation ist derzeit nicht online buchbar. Bitte wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')]));
}
$pressRelease = PressRelease::query()
->accessibleBy($user)
->whereKey($pressReleaseId)
->firstOrFail();
if ($pressRelease->status !== PressReleaseStatus::Published) {
return $this->backToBookings(__('Nur veröffentlichte Pressemitteilungen können depubliziert werden.'));
}
if (! $user->hasCompleteBillingAddress()) {
return $this->backToProfile();
}
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
return $redirect;
}
// Doppelbuchungs-Bremse: nur eine BEZAHLTE Depublikation blockiert.
// Ein offener Pending-Kauf (abgebrochener Stripe-Checkout) wird
// wiederverwendet, damit der Abbruch den Weg nicht dauerhaft
// versperrt (QA-Befund 07.07.).
$existing = SinglePurchase::query()
->where('press_release_id', $pressRelease->id)
->where('type', SinglePurchaseType::Depublication->value)
->whereIn('status', [SinglePurchaseStatus::Pending->value, SinglePurchaseStatus::Paid->value])
->latest('id')
->first();
if ($existing && $existing->status !== SinglePurchaseStatus::Pending) {
return $this->backToBookings(__('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.'));
}
$purchase = $existing ?? SinglePurchase::query()->create([
'user_id' => $user->id,
'type' => SinglePurchaseType::Depublication->value,
'status' => SinglePurchaseStatus::Pending->value,
'price_cents' => $priceCents,
'currency' => config('billing.default_currency'),
'press_release_id' => $pressRelease->id,
]);
return $this->checkout->forDepublication($user, $purchase);
}
/**
* Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode,
* Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll.
@ -111,6 +255,23 @@ class CheckoutController extends Controller
->with('checkout-notice', $notice);
}
/**
* B2B-Gate (T1neu, AGB v0.6 § 1 Abs. 4): Jeder Stripe-Checkout setzt eine
* zeitnah protokollierte Unternehmer-Bestätigung voraus (Pflicht-Checkbox
* im Buchungs-Modal). Direktaufrufe der Checkout-Routen ohne Bestätigung
* werden auf die Buchungsseite zurückgeleitet.
*/
private function requireEntrepreneurConfirmation(Request $request): ?RedirectResponse
{
if (BookingConfirmation::query()->recentFor($request->user())->exists()) {
return null;
}
return $this->backToBookings(
__('Bitte bestätigen Sie im Buchungsdialog, dass Sie als Unternehmer bestellen — das Angebot richtet sich ausschließlich an Unternehmen, Selbstständige und Agenturen.')
);
}
/**
* Buchungs-Voraussetzung (12.06.2026): ohne vollständige
* Rechnungsadresse kein Checkout — der Hinweis erscheint direkt auf

View file

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers;
use App\Enums\EmailNotificationType;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\URL;
use Illuminate\View\View;
/**
* One-Click-Abmeldung von Benachrichtigungs-Mails, ohne Login.
*
* Die Links in den Mail-Footern sind signiert und ohne Ablauf (Links aus
* alten Mails bleiben gültig). Der GET-Aufruf trägt den Empfänger direkt
* aus und zeigt die Bestätigungsseite mit der Möglichkeit, die Abmeldung
* rückgängig zu machen. Der POST auf dieselbe URL bedient den
* List-Unsubscribe-Header (RFC 8058) — Mail-Clients senden ihn ohne
* Nutzer-Interaktion auf der Seite.
*/
class EmailUnsubscribeController extends Controller
{
public function show(Request $request, User $user, string $type): View
{
$notificationType = $this->resolveType($type);
if (! $request->hasValidSignature()) {
return view('web.email-unsubscribe', [
'state' => 'invalid',
'notificationType' => $notificationType,
]);
}
$user->optOutOfEmailNotification($notificationType);
return view('web.email-unsubscribe', [
'state' => 'unsubscribed',
'notificationType' => $notificationType,
'resubscribeUrl' => URL::signedRoute('email-preferences.resubscribe', [
'user' => $user->id,
'type' => $notificationType->value,
]),
]);
}
/**
* RFC-8058-Endpunkt: Mail-Clients senden `List-Unsubscribe=One-Click`
* per POST, ohne die Seite zu öffnen. Antwort bewusst schlicht (2xx).
*/
public function oneClickUnsubscribe(Request $request, User $user, string $type): Response
{
abort_unless($request->hasValidSignature(), 403);
$user->optOutOfEmailNotification($this->resolveType($type));
return response('Sie wurden erfolgreich abgemeldet.');
}
public function resubscribe(Request $request, User $user, string $type): View|RedirectResponse
{
abort_unless($request->hasValidSignature(), 403);
$notificationType = $this->resolveType($type);
$user->optIntoEmailNotification($notificationType);
return view('web.email-unsubscribe', [
'state' => 'resubscribed',
'notificationType' => $notificationType,
]);
}
private function resolveType(string $type): EmailNotificationType
{
$notificationType = EmailNotificationType::tryFrom($type);
abort_if($notificationType === null, 404);
return $notificationType;
}
}

View file

@ -5,7 +5,6 @@ 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;
@ -23,9 +22,7 @@ class LegacyInvoicePdfController extends Controller
]);
}
if (Schema::hasColumn('legacy_invoices', 'pdf_generated_at')) {
$legacyInvoice->forceFill(['pdf_generated_at' => now()])->save();
}
$legacyInvoice->forceFill(['pdf_generated_at' => now()])->save();
return $renderer->inlineResponse($legacyInvoice);
}

View file

@ -6,11 +6,14 @@ use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
@ -39,6 +42,13 @@ class LegalRequestController extends Controller
{
$release = $this->resolvePublishedRelease($slug);
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
// nichts speichern. Bewusst VOR der Validierung — eine size:0-Regel
// würde mit ihrer Fehlermeldung einem Bot die Falle verraten.
if (filled($request->input('website')) || filled($request->input('homepage'))) {
return $this->done($release);
}
$validated = $request->validate([
'type' => ['required', Rule::enum(LegalRequestType::class)],
// E-Mail für DSGVO/Persönlichkeitsrecht nötig (Rückmeldung); bei
@ -51,19 +61,10 @@ class LegalRequestController extends Controller
'nullable', 'email', 'max:255',
],
'message' => ['required', 'string', 'min:10', 'max:5000'],
// Honeypots: müssen leer bleiben (versteckte Felder, nur für Bots).
'website' => ['nullable', 'size:0'],
'homepage' => ['nullable', 'size:0'],
]);
$type = LegalRequestType::from($validated['type']);
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
// nichts speichern (keine Fehlermeldung, die einem Bot etwas verrät).
if (filled($request->input('website')) || filled($request->input('homepage'))) {
return $this->done($release);
}
$this->ensureNotRateLimited($release);
// Bremse: max. 1 offene Anfrage pro PM + Typ (nicht hart gesperrt – andere
@ -80,7 +81,7 @@ class LegalRequestController extends Controller
RateLimiter::hit($this->throttleKey($release), 3600);
LegalRequest::query()->create([
$legalRequest = LegalRequest::query()->create([
'press_release_id' => $release->getKey(),
'portal' => $release->portal?->value,
'type' => $type->value,
@ -90,7 +91,23 @@ class LegalRequestController extends Controller
'requester_ip' => $request->ip(),
]);
return $this->done($release);
// T10/DSA Art. 16 Abs. 4: unverzügliche Eingangsbestätigung mit
// Vorgangsnummer (sofern eine E-Mail-Adresse angegeben wurde) +
// interner Hinweis ans Melde-Postfach.
if ($legalRequest->requester_email) {
Mail::to($legalRequest->requester_email)->queue(
(new LegalRequestReceived($legalRequest))->afterCommit()
);
}
Mail::to($this->operatorRecipient())->queue(
(new LegalRequestSubmitted($legalRequest))->afterCommit()
);
return redirect()
->route('release.detail', ['slug' => $release->slug])
->with('legal-status', __('Vielen Dank. Ihre Anfrage ist unter der Vorgangsnummer :reference eingegangen und wird von unserem Team geprüft.', [
'reference' => $legalRequest->reference(),
]));
}
private function done(PressRelease $release): RedirectResponse
@ -100,6 +117,15 @@ class LegalRequestController extends Controller
->with('legal-status', __('Vielen Dank. Ihre Anfrage ist eingegangen und wird von unserem Team geprüft.'));
}
private function operatorRecipient(): string
{
$brand = str_contains(request()->getHost(), 'presseecho')
? config('domains.domains.presseecho.brand', [])
: config('domains.domains.businessportal24.brand', []);
return $brand['report_email'] ?? (string) config('legal.contact_email');
}
private function resolvePublishedRelease(string $slug): PressRelease
{
$portal = str_contains(request()->getHost(), 'presseecho')

View file

@ -2,14 +2,17 @@
namespace App\Http\Controllers;
use App\Enums\Portal;
use App\Http\Middleware\SetEdition;
use App\Models\MagicLink;
use App\Models\PressRelease;
use Illuminate\Contracts\View\View;
use App\Support\DomainAssetContext;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
class PressReleasePreviewController extends Controller
{
public function __invoke(string $token): View|Response
public function __invoke(string $token): Response
{
$magicLink = MagicLink::query()
->where('token_hash', hash('sha256', $token))
@ -28,7 +31,14 @@ class PressReleasePreviewController extends Controller
$pressRelease = $pressReleaseId
? PressRelease::withoutGlobalScopes()
->with(['company:id,name,slug', 'category.translations', 'images', 'user:id,name'])
->with([
'company',
'user:id,agency_name',
'category.translations',
'category.parent.translations',
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order'),
'contacts',
])
->find($pressReleaseId)
: null;
@ -36,10 +46,81 @@ class PressReleasePreviewController extends Controller
return $this->renderError(__('Die Pressemitteilung wurde nicht gefunden.'), 404);
}
return view('press-release-preview', [
'pressRelease' => $pressRelease,
'expiresAt' => $magicLink->expires_at,
]);
$domainKey = $this->resolveTargetDomainKey($pressRelease);
$this->applyTargetPortalPresentation($pressRelease, $domainKey);
return response()
->view('web.release-detail', [
'release' => $pressRelease,
'companyReleases' => collect(),
'relatedReleases' => collect(),
'isPreview' => true,
'previewExpiresAt' => $magicLink->expires_at,
'previewPortalOptions' => $this->previewPortalOptions($pressRelease, $domainKey),
])
->header('X-Robots-Tag', 'noindex, nofollow');
}
/**
* Zielportal der Vorschau. Bei PMs für beide Portale wählt der optionale
* Query-Parameter `portal` die Ansicht (Default: presseecho); bei
* Einzel-Portal-PMs ist das Portal fix.
*/
private function resolveTargetDomainKey(PressRelease $pressRelease): string
{
if ($pressRelease->portal === Portal::Both) {
$requested = request()->query('portal');
return in_array($requested, [Portal::Presseecho->value, Portal::Businessportal24->value], true)
? $requested
: Portal::Presseecho->value;
}
return $pressRelease->portal === Portal::Businessportal24
? Portal::Businessportal24->value
: Portal::Presseecho->value;
}
/**
* Umschalt-Optionen für den Vorschau-Banner — nur bei PMs, die auf beiden
* Portalen erscheinen.
*
* @return list<array{label: string, url: string, active: bool}>|null
*/
private function previewPortalOptions(PressRelease $pressRelease, string $activeDomainKey): ?array
{
if ($pressRelease->portal !== Portal::Both) {
return null;
}
return collect([Portal::Presseecho->value, Portal::Businessportal24->value])
->map(fn (string $domainKey): array => [
'label' => $domainKey,
'url' => request()->url().'?portal='.$domainKey,
'active' => $domainKey === $activeDomainKey,
])
->all();
}
/**
* Rendert die Vorschau im Look des Zielportals: Theme-/Asset-Konfiguration
* der Web-Domain anwenden, Links auf das Zielportal zeigen lassen und die
* Ausgabe-Sprache der PM übernehmen — damit die Vorschau 1:1 der späteren
* öffentlichen Detailseite entspricht.
*/
private function applyTargetPortalPresentation(PressRelease $pressRelease, string $domainKey): void
{
$domainConfig = DomainAssetContext::applyWebDomainConfig($domainKey);
URL::forceRootUrl($domainConfig['url'] ?? config('app.url'));
$language = in_array($pressRelease->language, SetEdition::EDITIONS, true)
? $pressRelease->language
: SetEdition::DEFAULT_EDITION;
app()->setLocale($language);
URL::defaults(['edition' => $language]);
}
private function renderError(string $message, int $status): Response

View file

@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\Invoice;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Cashier;
/**
* Öffnet die bei Stripe gehostete Rechnungsansicht (inkl. PDF-Download) zu
* einer gespiegelten STR-Rechnung. Die URL wird bei jedem Aufruf frisch von
* Stripe geholt statt gespeichert — gehostete Links können sich ändern.
*/
class StripeInvoiceRedirectController extends Controller
{
public function __invoke(Request $request, Invoice $invoice): RedirectResponse
{
abort_unless($invoice->user_id === $request->user()->id, 404);
abort_unless(filled($invoice->stripe_invoice_id), 404);
try {
$stripeInvoice = Cashier::stripe()->invoices->retrieve($invoice->stripe_invoice_id);
$hostedUrl = $stripeInvoice->hosted_invoice_url;
} catch (\Throwable $exception) {
Log::warning('Stripe-Rechnungsansicht nicht erreichbar.', [
'invoice_id' => $invoice->id,
'stripe_invoice_id' => $invoice->stripe_invoice_id,
'error' => $exception->getMessage(),
]);
$hostedUrl = null;
}
if (! $hostedUrl) {
return redirect()
->route('me.invoices.index')
->with('invoice-error', __('Die Stripe-Rechnungsansicht ist gerade nicht erreichbar. Bitte versuchen Sie es später erneut.'));
}
return redirect()->away($hostedUrl);
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\Web\CategoryPageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
class CategoryController extends Controller
{
// $edition stammt aus dem Gruppen-Prefix {edition} und steht positionsbedingt
// vor {slug}; es wird hier nicht gebraucht, muss aber in der Signatur stehen.
public function __invoke(string $edition, string $slug, CategoryPageData $pageData): View
{
return view('web.kategorie', $pageData->build(WebPortalContext::fromRequest(), $slug));
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Web\Concerns;
use App\Models\PressRelease;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
/**
* Gemeinsame Zugriffslogik der geschützten Presse-Medienrouten (Bilder und
* Anhänge): Öffentlich sichtbare PMs sind frei abrufbar; sonst braucht es eine
* gültige Signatur (anonyme Token-Vorschau) oder eine angemeldete Session mit
* `view`-Recht auf die PM.
*/
trait AuthorizesPressReleaseMediaAccess
{
protected function authorizePressReleaseMediaAccess(Request $request, PressRelease $pressRelease, bool $isPublic): void
{
if ($isPublic) {
return;
}
// Signierte URL (Vorschau-Link) autorisiert den Zugriff auf die Medien
// einer noch nicht öffentlichen PM, ohne eine Session zu erfordern.
if ($request->hasValidSignature()) {
return;
}
abort_unless(auth()->check(), 404);
Gate::authorize('view', $pressRelease);
}
/**
* Öffentliche Sichtbarkeit aus den bereits geladenen Modellen ermitteln
* (PM inkl. `company` werden vom Controller eager-geladen), statt eine
* separate EXISTS-Abfrage je Medien-Request zu fahren.
*/
protected function pressReleaseMediaIsPublic(PressRelease $pressRelease): bool
{
$company = $pressRelease->company;
return $pressRelease->isVisibleInArchive()
&& $company !== null
&& (bool) $company->is_active;
}
/**
* Cache-Control für die Auslieferung. Öffentliche Medien werden lange
* gecacht (Browser/CDN), damit die PHP-Route auf den hochfrequenten
* Archiv-/Detailseiten nicht bei jedem Bild erneut getroffen wird; nicht
* öffentliche (Vorschau/geschützt) werden nie gecacht.
*/
protected function mediaCacheControl(bool $isPublic): string
{
return $isPublic ? 'public, max-age=86400' : 'private, no-store';
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Web;
use App\Enums\ContactMessageTopic;
use App\Http\Controllers\Controller;
use App\Mail\ContactRequestSubmitted;
use App\Models\ContactMessage;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
* Öffentliches Kontaktformular der Web-Domains (Hub-Modal + schwebendes
* Kontakt-Widget). Persistiert das Anliegen in der Admin-Queue „Kontakt"
* (contact_messages) und versendet zusätzlich eine Mail an die Brand-
* Kontaktadresse (config domains.*.brand.contact_email, Fallback:
* Absenderadresse der Anwendung).
*/
class ContactController extends Controller
{
public function __invoke(Request $request): RedirectResponse
{
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
// bewusst VOR der Validierung (sonst verrät die size:0-Regel die Falle).
if (filled($request->input('website')) || filled($request->input('homepage'))) {
return $this->done();
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'message' => ['required', 'string', 'min:10', 'max:5000'],
'topic' => ['nullable', Rule::enum(ContactMessageTopic::class)],
// Nur http/https: page_url wird im Admin-Backend als klickbarer Link
// gerendert — verhindert eingeschleuste javascript:-URIs (Stored XSS).
'page_url' => ['nullable', 'url:http,https', 'max:500'],
], [
'name.required' => __('web.launcher.validation_name'),
'name.max' => __('web.launcher.validation_name'),
'email.required' => __('web.launcher.validation_email'),
'email.email' => __('web.launcher.validation_email'),
'email.max' => __('web.launcher.validation_email'),
'message.required' => __('web.launcher.validation_message'),
'message.min' => __('web.launcher.validation_message_min'),
'message.max' => __('web.launcher.validation_message_max'),
]);
$this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600);
$topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact;
ContactMessage::create([
'portal' => $this->portal($request),
'topic' => $topic,
'name' => $validated['name'],
'email' => $validated['email'],
'message' => $validated['message'],
'page_url' => $validated['page_url'] ?? null,
'requester_ip' => $request->ip(),
'user_id' => $request->user()?->id,
]);
Mail::to($this->recipient())->queue((new ContactRequestSubmitted(
senderName: $validated['name'],
senderEmail: $validated['email'],
messageText: $validated['message'],
host: $request->getHost(),
topicLabel: $topic->label(),
pageUrl: $validated['page_url'] ?? null,
))->afterCommit());
return $this->done();
}
private function done(): RedirectResponse
{
return back()->with('contact-status', __('web.launcher.success'));
}
/**
* Portal-Kennung für die Admin-Queue, aus dem Host abgeleitet:
* Hub (pressekonto) vs. Spoke-Portale.
*/
private function portal(Request $request): string
{
if (str_contains($request->getHost(), 'pressekonto')) {
return 'pressekonto';
}
return WebPortalContext::portalFromHost()->value;
}
private function recipient(): string
{
// Auch die Hub-Domain (pressekonto) nutzt das Formular — sie ist kein
// Spoke-Portal, daher vor dem WebPortalContext am Host unterscheiden.
if (str_contains(request()->getHost(), 'pressekonto')) {
$brand = config('domains.domains.pressekonto.brand', []);
} else {
$brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? [];
}
return $brand['contact_email'] ?? config('mail.from.address');
}
private function ensureNotRateLimited(Request $request): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey($request));
throw ValidationException::withMessages([
'message' => __('web.launcher.throttle', [
'minutes' => max(1, (int) ceil($seconds / 60)),
]),
]);
}
private function throttleKey(Request $request): string
{
return 'contact-form|'.$request->ip();
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Web;
use App\Enums\Portal;
use App\Http\Controllers\Controller;
use App\Services\Web\HomePageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
class HomeController extends Controller
{
public function __invoke(HomePageData $pageData): View|RedirectResponse
{
// Der Hub (pressekonto) hat keine Ausgaben-Startseite; auf die Bare-Root.
if (str_contains(request()->getHost(), 'pressekonto')) {
return redirect(request()->getSchemeAndHttpHost().'/', 301);
}
$context = WebPortalContext::fromRequest();
$view = $context->portal === Portal::Presseecho
? 'web.presseecho'
: 'web.businessportal24';
return view($view, $pageData->build($context->portal, $context->language));
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\PressReleaseImage;
use Illuminate\Http\RedirectResponse;
/**
* Redirect alter Symfony-Medien-URLs (QA-Punkt "Alter /thumbnails/...-Link
* redirectet korrekt", 06-FEATURES-SCOPE §11/§15): Die Altportale lieferten
* Bilder unter /uploads/... und /thumbnails/...; der Medien-Import
* (migrate:legacy-media) behält den Original-Dateinamen im neuen Pfad —
* darüber wird hier 301 auf die neue Storage-URL geleitet. Unbekannte
* Dateien bleiben 404.
*/
class LegacyMediaRedirectController extends Controller
{
public function __invoke(string $prefix, string $dir, string $path): RedirectResponse
{
$filename = basename($path);
abort_if($filename === '' || $filename === '.', 404);
$target = str_contains($dir, 'company')
? $this->companyLogoUrl($filename)
: $this->pressReleaseImageUrl($filename);
abort_if($target === null, 404);
return redirect($target, 301);
}
private function pressReleaseImageUrl(string $filename): ?string
{
$image = PressReleaseImage::query()
->where('path', 'like', '%/'.$filename)
->orderBy('id')
->first();
return $image?->url();
}
private function companyLogoUrl(string $filename): ?string
{
$company = Company::query()
->withoutGlobalScopes()
->where('logo_path', 'like', '%/'.$filename)
->orderBy('id')
->first();
return $company?->logoUrl();
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\Web\NewsroomDetailPageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
class NewsroomDetailController extends Controller
{
// $edition stammt aus dem Gruppen-Prefix {edition} und steht positionsbedingt
// vor {slug}; es wird hier nicht gebraucht, muss aber in der Signatur stehen.
public function __invoke(string $edition, string $slug, NewsroomDetailPageData $pageData): View
{
return view('web.newsroom', $pageData->build(WebPortalContext::fromRequest(), $slug));
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\Web\NewsroomsPageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
class NewsroomsController extends Controller
{
public function __invoke(NewsroomsPageData $pageData): View
{
return view('web.newsrooms', $pageData->build(WebPortalContext::fromRequest()));
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\Web\PreisePageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
class PreiseController extends Controller
{
public function __invoke(PreisePageData $pageData): View
{
WebPortalContext::fromRequest();
return view('web.preise', $pageData->build());
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AuthorizesPressReleaseMediaAccess;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PressReleaseAttachmentMediaController extends Controller
{
use AuthorizesPressReleaseMediaAccess;
public function __invoke(Request $request, int $attachment): StreamedResponse
{
$attachment = PressReleaseAttachment::query()
->with(['pressRelease' => fn ($query) => $query
->withoutGlobalScopes()
->with(['company' => fn ($company) => $company->withoutGlobalScopes()])])
->findOrFail($attachment);
$pressRelease = $attachment->pressRelease;
abort_unless($pressRelease instanceof PressRelease, 404);
$isPublic = $this->pressReleaseMediaIsPublic($pressRelease);
$this->authorizePressReleaseMediaAccess($request, $pressRelease, $isPublic);
return Storage::disk($attachment->disk)->download(
$attachment->path,
$attachment->original_name,
[
'Cache-Control' => $this->mediaCacheControl($isPublic),
'X-Content-Type-Options' => 'nosniff',
],
);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AuthorizesPressReleaseMediaAccess;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PressReleaseImageMediaController extends Controller
{
use AuthorizesPressReleaseMediaAccess;
public function __invoke(Request $request, int $image, ?string $variant = null): StreamedResponse
{
$image = PressReleaseImage::query()
->with(['pressRelease' => fn ($query) => $query
->withoutGlobalScopes()
->with(['company' => fn ($company) => $company->withoutGlobalScopes()])])
->findOrFail($image);
$pressRelease = $image->pressRelease;
abort_unless($pressRelease instanceof PressRelease, 404);
$isPublic = $this->pressReleaseMediaIsPublic($pressRelease);
$this->authorizePressReleaseMediaAccess($request, $pressRelease, $isPublic);
$path = $image->pathForVariant($variant);
abort_unless(is_string($path) && filled($path), 404);
return Storage::disk($image->disk)->response($path, headers: [
'Cache-Control' => $this->mediaCacheControl($isPublic),
'X-Content-Type-Options' => 'nosniff',
]);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\Web\ReleaseDetailPageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Response;
class ReleaseDetailController extends Controller
{
// $edition stammt aus dem Gruppen-Prefix {edition} und steht positionsbedingt
// vor {slug}; es wird hier nicht gebraucht, muss aber in der Signatur stehen.
public function __invoke(string $edition, string $slug, ReleaseDetailPageData $pageData): View|Response
{
$data = $pageData->build(WebPortalContext::fromRequest(), $slug);
// T12: depublizierte PM → Tombstone mit 410 Gone (ohne Inhalte).
if (isset($data['tombstone'])) {
return response()->view('web.release-tombstone', [], 410);
}
return view('web.release-detail', $data);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Web;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\Rule;
/**
* Nimmt Share-Klicks der öffentlichen PM-Detailseite entgegen und loggt
* sie als Ereignis (Kanal: LinkedIn/X/E-Mail/Link kopiert) — ohne
* Personenbezug. Nur für im Archiv sichtbare PMs des aufrufenden Portals.
*/
class ReleaseShareTrackingController extends Controller
{
public function __invoke(Request $request, string $edition, string $slug): Response
{
$validated = $request->validate([
'channel' => ['required', Rule::enum(PressReleaseShareChannel::class)],
]);
$context = WebPortalContext::fromRequest();
$release = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($context->portalValues)
->forLanguage($context->language)
->visibleInArchive()
->where('slug', $slug)
->firstOrFail();
$release->events()->create([
'type' => PressReleaseEventType::Share,
'channel' => $validated['channel'],
]);
return response()->noContent();
}
}

View file

@ -0,0 +1,160 @@
<?php
namespace App\Http\Controllers\Web;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
/**
* Eigenständige Meldeformulare (T10, Meldeverfahren v0.1): DSA-Meldung
* rechtswidriger Inhalte ("rechtswidrig") und DSGVO-Antrag auf Entfernung
* eigener Kontaktdaten ("kontaktdaten") — mit URL-Feld statt Bindung an
* eine konkrete Pressemitteilung. Speist dieselbe Admin-Queue wie die
* Melde-Links an den Mitteilungen; die meldende Person erhält unverzüglich
* eine Eingangsbestätigung mit Vorgangsnummer (DSA Art. 16 Abs. 4).
*/
class ReportFormController extends Controller
{
public const WAYS = ['rechtswidrig', 'kontaktdaten'];
public function show(string $edition, string $way): View
{
return view('web.melden-form', ['way' => $way]);
}
public function store(Request $request, string $edition, string $way): RedirectResponse
{
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
// bewusst VOR der Validierung (sonst verrät die Regel die Falle).
if (filled($request->input('website')) || filled($request->input('homepage'))) {
return back()->with('report-status', __('static.report_form.flash_honeypot'));
}
$validated = $request->validate([
'reported_url' => ['required', 'url', 'max:500'],
'requester_name' => ['required', 'string', 'max:120'],
'requester_email' => ['required', 'email', 'max:255'],
'message' => ['required', 'string', 'min:20', 'max:5000'],
// DSA Art. 16 Abs. 1 lit. d: Erklärung, dass die Meldung nach
// bestem Wissen und Gewissen abgegeben wird (nur Weg 1).
'truth_affirmed' => $way === 'rechtswidrig' ? ['accepted'] : ['nullable'],
], [
'truth_affirmed.accepted' => __('static.report_form.validation_truth'),
]);
$this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600);
$release = $this->matchPublishedRelease($validated['reported_url']);
$legalRequest = LegalRequest::query()->create([
'press_release_id' => $release?->getKey(),
'portal' => $release?->portal?->value ?? $this->portalFromHost($request),
'type' => $way === 'kontaktdaten' ? LegalRequestType::Dsgvo->value : LegalRequestType::Report->value,
'status' => LegalRequestStatus::Open->value,
'requester_email' => $validated['requester_email'],
'message' => $validated['message'],
'payload' => [
'way' => $way,
'reported_url' => $validated['reported_url'],
'requester_name' => $validated['requester_name'],
'truth_affirmed' => $request->boolean('truth_affirmed'),
],
'requester_ip' => $request->ip(),
]);
// Eingangsbestätigung (DSA Art. 16 Abs. 4) + interner Hinweis ans
// Melde-Postfach. Versand über die Queue (afterCommit), damit die
// Response nicht am SMTP-Roundtrip hängt — setzt einen laufenden
// Queue-Worker voraus (Ops-Monitoring erforderlich, da die Zustellung
// fristrelevant ist).
Mail::to($legalRequest->requester_email)->queue(
(new LegalRequestReceived($legalRequest))->afterCommit()
);
Mail::to($this->operatorRecipient($request))->queue(
(new LegalRequestSubmitted($legalRequest))->afterCommit()
);
return back()->with('report-status', __('static.report_form.flash_received', [
'reference' => $legalRequest->reference(),
]));
}
/**
* Ordnet die gemeldete URL einer veröffentlichten Pressemitteilung zu,
* wenn sie einer Detail-URL entspricht — sonst bleibt die Meldung ohne
* PM-Bezug in der Queue (URL steht im payload).
*/
private function matchPublishedRelease(string $url): ?PressRelease
{
if (! preg_match('#/press-release/([^/?\#]+)#', $url, $matches)) {
return null;
}
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->where('slug', urldecode($matches[1]))
->where('status', PressReleaseStatus::Published)
->first();
}
private function portalFromHost(Request $request): ?string
{
$host = $request->getHost();
if (str_contains($host, 'presseecho')) {
return 'presseecho';
}
if (str_contains($host, 'businessportal')) {
return 'businessportal24';
}
return null; // Hub: Meldung ohne Portal-Zuordnung.
}
private function operatorRecipient(Request $request): string
{
if (str_contains($request->getHost(), 'pressekonto')) {
$brand = config('domains.domains.pressekonto.brand', []);
} else {
$brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? [];
}
return $brand['report_email'] ?? (string) config('legal.contact_email');
}
private function ensureNotRateLimited(Request $request): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey($request));
throw ValidationException::withMessages([
'message' => __('static.report_form.throttle', [
'minutes' => max(1, (int) ceil($seconds / 60)),
]),
]);
}
private function throttleKey(Request $request): string
{
return 'report-form|'.$request->ip();
}
}

View file

@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* Host-aware XML-Sitemap: Der Hub listet Landing- und Rechts-/Info-Seiten,
* die Spokes (presseecho, businessportal24) zusätzlich Kategorien, Newsrooms
* und alle im Archiv sichtbaren Pressemitteilungen ihres Portals. Marketing-
* Platzhalterseiten (team, presse, karriere, partner, kontakt, ueber-uns)
* und die Suche bleiben bewusst draußen (R2.4/R3.2). Pro Host eine Stunde
* gecacht.
*/
class SitemapController extends Controller
{
/** @var list<string> */
private const HUB_PAGES = [
'faq', 'hilfe', 'api', 'kuendigung',
'impressum', 'datenschutz', 'agb', 'richtlinien', 'melden',
];
/** @var list<string> */
private const PORTAL_PAGES = [
'preise', 'veroeffentlichen', 'newsrooms', 'faq', 'hilfe', 'api',
'impressum', 'datenschutz', 'agb', 'richtlinien', 'melden',
];
public function __invoke(Request $request): Response
{
$host = $request->getHost();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$isHub = str_contains($host, 'pressekonto');
$xml = Cache::remember('web.sitemap.'.$host, 3600, function () use ($base, $isHub): string {
$urls = $isHub ? $this->hubUrls($base) : $this->portalUrls($base);
return view('web.sitemap', ['urls' => $urls])->render();
});
return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
}
/**
* @return list<array{loc: string, lastmod: ?string}>
*/
private function hubUrls(string $base): array
{
$urls = [['loc' => $base.'/', 'lastmod' => null]];
foreach (self::HUB_PAGES as $page) {
$urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null];
}
return $urls;
}
/**
* @return list<array{loc: string, lastmod: ?string}>
*/
private function portalUrls(string $base): array
{
$context = WebPortalContext::fromRequest();
$urls = [['loc' => $base.'/de', 'lastmod' => null]];
foreach (self::PORTAL_PAGES as $page) {
$urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null];
}
// Kategorien über die lokalisierten Slugs (CategoryTranslation).
$categorySlugs = Category::query()
->where('is_active', true)
->whereIn('portal', $context->portalValues)
->with('translations')
->get()
->map(fn (Category $category) => $category->translations->firstWhere('locale', 'de')?->slug)
->filter()
->unique();
foreach ($categorySlugs as $slug) {
$urls[] = ['loc' => $base.'/de/category/'.$slug, 'lastmod' => null];
}
// Öffentliche Newsrooms: aktive Firmen mit mindestens einer im Archiv
// sichtbaren PM des Portals (gleiche Regel wie NewsroomsPageData).
$companies = Company::query()
->where('is_active', true)
->whereHas('pressReleases', fn ($query) => $query
->withoutGlobalScope(PortalScope::class)
->visibleInArchive()
->forPortalValues($context->portalValues))
->get(['slug', 'updated_at']);
foreach ($companies as $company) {
$urls[] = [
'loc' => $base.'/de/newsroom/'.$company->slug,
'lastmod' => $company->updated_at?->toAtomString(),
];
}
// Alle im Archiv sichtbaren Pressemitteilungen des Portals.
$releases = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->visibleInArchive()
->forPortalValues($context->portalValues)
->orderByDesc('published_at')
->get(['slug', 'published_at', 'updated_at']);
foreach ($releases as $release) {
$urls[] = [
'loc' => $base.'/de/press-release/'.$release->slug,
'lastmod' => ($release->updated_at ?? $release->published_at)?->toAtomString(),
];
}
return $urls;
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\Web\SuchePageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
class SucheController extends Controller
{
public function __invoke(SuchePageData $pageData): View
{
return view('web.suche', $pageData->build(WebPortalContext::fromRequest()));
}
}

View file

@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Mail\TerminationRequestReceived;
use App\Mail\TerminationRequestSubmitted;
use App\Models\TerminationRequest;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
/**
* Gesetzlicher Kündigungsbutton (§ 312k BGB): Verbraucher können laufende
* Verträge (Abos) ohne Login über eine öffentlich erreichbare
* Bestätigungsseite kündigen. Erfasst werden Art der Kündigung (Grund nur
* bei außerordentlicher Kündigung), Zuordnungsdaten und der gewünschte
* Beendigungszeitpunkt. Der Zugang der Erklärung wird sofort automatisch
* per E-Mail bestätigt (Inhalt, Datum und Uhrzeit des Zugangs).
*/
class TerminationController extends Controller
{
/**
* Bestätigungsseite mit dem Kündigungsformular (§ 312k Abs. 2 BGB).
*/
public function show(): View
{
return view('web.kuendigung');
}
public function store(Request $request): RedirectResponse
{
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
// bewusst VOR der Validierung (sonst verrät die Regel die Falle).
if (filled($request->input('website')) || filled($request->input('homepage'))) {
return $this->done();
}
// Eigener Error-Bag, damit sich das Kontakt-Modal im Hub-Footer
// (öffnet auf Default-Bag-Fehler) nicht mit öffnet.
$validated = $request->validateWithBag('termination', [
'kind' => ['required', 'in:ordentlich,ausserordentlich'],
'reason' => ['required_if:kind,ausserordentlich', 'nullable', 'string', 'max:2000'],
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'contract_reference' => ['nullable', 'string', 'max:255'],
'terminate_mode' => ['required', 'in:asap,date'],
'terminate_at' => ['required_if:terminate_mode,date', 'nullable', 'date'],
], [
'reason.required_if' => __('static.cancellation.validation_reason'),
'terminate_at.required_if' => __('static.cancellation.validation_date'),
]);
$this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600);
$terminationRequest = TerminationRequest::query()->create([
'kind' => $validated['kind'],
'reason' => $validated['kind'] === 'ausserordentlich' ? ($validated['reason'] ?? null) : null,
'name' => $validated['name'],
'email' => $validated['email'],
'contract_reference' => $validated['contract_reference'] ?? null,
'terminate_at' => $validated['terminate_mode'] === 'date' ? $validated['terminate_at'] : null,
'host' => $request->getHost(),
'requester_ip' => $request->ip(),
]);
// § 312k Abs. 4 BGB: Zugang der Kündigungserklärung sofort auf
// elektronischem Wege bestätigen (Inhalt, Datum, Uhrzeit).
Mail::to($terminationRequest->email)->queue(
(new TerminationRequestReceived($terminationRequest))->afterCommit()
);
Mail::to($this->operatorRecipient())->queue(
(new TerminationRequestSubmitted($terminationRequest))->afterCommit()
);
return $this->done();
}
private function done(): RedirectResponse
{
return back()->with('termination-status', __('static.cancellation.flash_received'));
}
private function operatorRecipient(): string
{
if (str_contains(request()->getHost(), 'pressekonto')) {
$brand = config('domains.domains.pressekonto.brand', []);
} else {
$brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? [];
}
return $brand['contact_email'] ?? config('mail.from.address');
}
private function ensureNotRateLimited(Request $request): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey($request));
throw ValidationException::withMessages([
'email' => __('static.cancellation.throttle', [
'minutes' => max(1, (int) ceil($seconds / 60)),
]),
])->errorBag('termination');
}
private function throttleKey(Request $request): string
{
return 'termination-form|'.$request->ip();
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\Web\VeroeffentlichenPageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
class VeroeffentlichenController extends Controller
{
public function __invoke(VeroeffentlichenPageData $pageData): View
{
return view('web.veroeffentlichen', $pageData->build(WebPortalContext::fromRequest()));
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Mail\WithdrawalRequestReceived;
use App\Mail\WithdrawalRequestSubmitted;
use App\Models\WithdrawalRequest;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
/**
* Online-Widerruf über den gesetzlichen Widerrufsbutton: ohne Login, nur
* Zuordnungsdaten (Name, E-Mail, optional Bestellnummer) — ein Widerrufsgrund
* darf nicht verlangt werden. Der Eingang wird sofort automatisch per E-Mail
* bestätigt (dauerhafter Datenträger inkl. Zeitstempel) und persistiert.
*/
class WithdrawalController extends Controller
{
public function __invoke(Request $request): RedirectResponse
{
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
// bewusst VOR der Validierung (sonst verrät die Regel die Falle).
if (filled($request->input('website')) || filled($request->input('homepage'))) {
return $this->done();
}
// Eigener Error-Bag, damit sich Widerrufs- und Kontakt-Modal auf
// derselben Seite nicht gegenseitig öffnen.
$validated = $request->validateWithBag('withdrawal', [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'order_reference' => ['nullable', 'string', 'max:255'],
]);
$this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600);
$withdrawalRequest = WithdrawalRequest::query()->create([
'name' => $validated['name'],
'email' => $validated['email'],
'order_reference' => $validated['order_reference'] ?? null,
'host' => $request->getHost(),
'requester_ip' => $request->ip(),
]);
// Gesetzlich vorgeschrieben: sofortige automatische Eingangsbestätigung.
Mail::to($withdrawalRequest->email)->queue(
(new WithdrawalRequestReceived($withdrawalRequest))->afterCommit()
);
Mail::to($this->operatorRecipient())->queue(
(new WithdrawalRequestSubmitted($withdrawalRequest))->afterCommit()
);
return $this->done();
}
private function done(): RedirectResponse
{
return back()->with('withdrawal-status', __('Ihr Widerruf ist bei uns eingegangen. Sie erhalten umgehend eine automatische Eingangsbestätigung per E-Mail.'));
}
private function operatorRecipient(): string
{
if (str_contains(request()->getHost(), 'pressekonto')) {
$brand = config('domains.domains.pressekonto.brand', []);
} else {
$brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? [];
}
return $brand['contact_email'] ?? config('mail.from.address');
}
private function ensureNotRateLimited(Request $request): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey($request));
throw ValidationException::withMessages([
'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
'minutes' => max(1, (int) ceil($seconds / 60)),
]),
])->errorBag('withdrawal');
}
private function throttleKey(Request $request): string
{
return 'withdrawal-form|'.$request->ip();
}
}

View file

@ -15,6 +15,11 @@ class BasicAuthMiddleware
*/
public function handle(Request $request, Closure $next): Response
{
// Gate global deaktiviert (z. B. Produktion/Tests)
if (! config('auth.basic.enabled', true)) {
return $next($request);
}
// Skip Basic Auth für Livewire-Requests komplett
// Diese sind bereits durch Laravel Session/CSRF geschützt
$path = $request->path();
@ -38,11 +43,27 @@ class BasicAuthMiddleware
return $next($request);
}
// Credentials from .env file
// Skip Basic Auth für den Stripe-Webhook — Stripe sendet keine
// Zugangsdaten mit; der Endpoint ist durch die Signaturprüfung
// (STRIPE_WEBHOOK_SECRET) geschützt.
if ($request->is('stripe/webhook')) {
return $next($request);
}
// Zugangsdaten aus der Config (gespeist aus .env). Ist nichts
// konfiguriert, wird der Gate nicht erzwungen.
$user = config('auth.basic.user');
$pass = config('auth.basic.password');
if ($request->getUser() != $user || $request->getPassword() != $pass) {
if (($user === null || $user === '') && ($pass === null || $pass === '')) {
return $next($request);
}
// Timing-sicherer Vergleich beider Felder (kein Short-Circuit).
$userValid = hash_equals((string) $user, (string) $request->getUser());
$passValid = hash_equals((string) $pass, (string) $request->getPassword());
if (! $userValid || ! $passValid) {
return response('Unauthorized.', 401, ['WWW-Authenticate' => 'Basic']);
}

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;
/**
* Sperrt den Admin-Bereich, solange eine Benutzer-Impersonation läuft. GET/HEAD
* werden auf das Kunden-Dashboard umgeleitet (der Admin agiert in der
* Kunden-Sicht), schreibende Zugriffe werden hart abgewiesen.
*
* Bewusst von der reinen Zugriffsprüfung ({@see EnsureUserIsAdmin}) getrennt:
* Impersonation ist eine Sitzungs-Bedingung, keine Rollenfrage.
*/
class BlockAdminDuringImpersonation
{
public function __construct(private readonly UserImpersonation $impersonation) {}
public function handle(Request $request, Closure $next): Response
{
if ($this->impersonation->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.');
}
return $next($request);
}
}

View file

@ -2,30 +2,22 @@
namespace App\Http\Middleware;
use App\Actions\Admin\UserImpersonation;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
/**
* Reine Zugriffsprüfung auf den Admin-Bereich über das `access-admin`-Gate. Die
* Impersonation-Sperre liegt in {@see BlockAdminDuringImpersonation} und läuft
* davor. Bleibt eine konkrete Klasse (statt `can:access-admin`), weil sie als
* persistente Livewire-Middleware registriert ist (AppServiceProvider).
*/
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()) {
if (Gate::denies('access-admin')) {
abort(403, 'Kein Zugriff auf den Admin-Bereich.');
}

View file

@ -4,13 +4,18 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
/**
* Zugriffsprüfung auf das Kundenportal („Mein Bereich") über das
* `access-customer`-Gate (Rollen admin/editor/customer, aktiv).
*/
class EnsureUserIsCustomer
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->canAccessCustomer()) {
if (Gate::denies('access-customer')) {
abort(403, 'Kein Zugriff auf das Kundenportal.');
}

View file

@ -10,6 +10,8 @@ use Symfony\Component\HttpFoundation\Response;
class LogApiUsage
{
private const StartedAtKey = 'api_usage_started_at';
/**
* Handle an incoming request.
*
@ -17,12 +19,22 @@ class LogApiUsage
*/
public function handle(Request $request, Closure $next): Response
{
$startedAt = microtime(true);
$response = $next($request);
$request->attributes->set(self::StartedAtKey, microtime(true));
return $next($request);
}
/**
* Der INSERT läuft erst nach gesendeter Antwort (terminable Middleware) und
* verlängert damit nicht mehr die wahrgenommene Request-Dauer. Der Startwert
* wird über die Request-Attribute übergeben, da `terminate()` auf einer
* frischen Middleware-Instanz aufgerufen wird.
*/
public function terminate(Request $request, Response $response): void
{
$startedAt = (float) $request->attributes->get(self::StartedAtKey, microtime(true));
$this->writeLog($request, $response, $startedAt);
return $response;
}
private function writeLog(Request $request, Response $response, float $startedAt): void

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Leitet www-Hosts kanonisch auf die Apex-Domain um (301, Pfad + Query
* bleiben erhalten): Jede Seite soll genau eine indexierbare URL haben,
* und Sessions/Cookies laufen nicht auf zwei Host-Varianten auseinander.
*
* Nur sichere Methoden (GET/HEAD/OPTIONS) werden umgeleitet — ein 301
* würde bei POST & Co. den Request-Body verwerfen; solche Requests
* beantwortet die App dank Host-Normalisierung (DomainAssetContext)
* auch auf www-Hosts korrekt.
*/
class RedirectWwwToApex
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$host = strtolower($request->getHost());
if (! str_starts_with($host, 'www.') || ! $request->isMethodSafe()) {
return $next($request);
}
return redirect()->away(
$request->getScheme().'://'.substr($host, 4).$request->getRequestUri(),
301
);
}
}

View file

@ -4,8 +4,17 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response;
/**
* Legacy-API-Kompatibilität (Übernahme bestehender Integrationen,
* 06.07.2026): Die Altportale authentifizierten per `X-ApiKey`-Header.
* Importierte Legacy-Keys (legacy:import-api-keys)
* sind gültige Sanctum-Tokens — hier wird die alte Übergabeform in einen
* Bearer-Header übersetzt, damit `auth:sanctum` sie versteht. Unbekannte
* Keys erhalten weiterhin 410 mit Migrationshinweis.
*/
class RejectLegacyApiKeys
{
/**
@ -15,14 +24,65 @@ class RejectLegacyApiKeys
*/
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);
$legacyKey = $this->legacyKey($request);
if ($legacyKey === null) {
// Kein Header-Key: ein in Query/Body übergebener Alt-Schlüssel wird
// NICHT für die Auth akzeptiert (Zugangsdaten dürfen nicht in URLs/
// Logs landen), erhält aber weiterhin den Migrationshinweis statt
// eines nichtssagenden 401 — sofern kein regulärer Bearer-Token da ist.
if (blank($request->bearerToken()) && $this->hasParameterKey($request)) {
return $this->migrationHintResponse();
}
return $next($request);
}
return $next($request);
// Ein explizit gesetzter Bearer-Token hat Vorrang.
if (filled($request->bearerToken())) {
return $next($request);
}
if (PersonalAccessToken::findToken($legacyKey) !== null) {
$request->headers->set('Authorization', 'Bearer '.$legacyKey);
return $next($request);
}
return $this->migrationHintResponse();
}
/**
* Legacy-Schlüssel ausschließlich aus Headern lesen. Query- und
* Body-Parameter werden bewusst nicht akzeptiert, damit Zugangsdaten
* nicht in URLs, Proxy-Logs oder Referrer-Headern landen.
*/
private function legacyKey(Request $request): ?string
{
$key = trim((string) (
$request->header('X-ApiKey')
?: $request->header('X-Api-Key')
));
return $key === '' ? null : $key;
}
/**
* Prüft, ob ein Alt-Schlüssel per Query- oder Body-Parameter übergeben
* wurde. Wird nur genutzt, um den Migrationshinweis auszulösen — der Wert
* wird nie zur Authentifizierung verwendet.
*/
private function hasParameterKey(Request $request): bool
{
return filled($request->query('api_key')) || filled($request->input('api_key'));
}
private function migrationHintResponse(): Response
{
return response()->json([
'message' => 'This API key is not valid (anymore). Please generate a token in your customer account and send it as a Bearer token or X-ApiKey header.',
'migration_url' => url('/customer/tokens'),
'docs_url' => url('/docs/api/v1'),
], 410);
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Applikationsseitige Security-Header (R6.2) — bewusst unabhängig vom
* Reverse-Proxy gesetzt, damit der Schutz auch bei Proxy-Wechsel bleibt.
* Bereits vorhandene Header (z. B. vom Proxy) werden nicht überschrieben.
* CSP fehlt absichtlich: Livewire/Alpine/Vite brauchen eine eigene
* Inline-Script-Strategie — separat einführen, nicht nebenbei.
*/
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
foreach ([
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'SAMEORIGIN',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
] as $header => $value) {
if (! $response->headers->has($header)) {
$response->headers->set($header, $value);
}
}
return $response;
}
}

View file

@ -44,13 +44,8 @@ class SetCurrentPortal
}
}
// 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
};
// Domain-basierte Auflösung via ThemeServiceProvider; Mapping zentral
// im Portal-Enum (Admin/Portal-Domain → null, kein automatischer Filter).
return Portal::fromTheme(config('app.theme', 'main'));
}
}

View file

@ -1,59 +0,0 @@
<?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

@ -1,57 +0,0 @@
<?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

@ -2,8 +2,10 @@
namespace App\Http\Requests\Api\V1;
use App\Enums\AiImageDeclaration;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePressReleaseImageRequest extends FormRequest
{
@ -18,12 +20,23 @@ class StorePressReleaseImageRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* KI-Kennzeichnung je Bild (Art. 50 KI-VO) ist im v1-Vertrag Pflicht;
* Legacy-/service-Clients kennen das Feld nicht — dort greift der
* Kompatibilitäts-Default „original" (Entscheidung 10.07.2026).
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$isLegacyServiceCall = str_starts_with((string) $this->route()?->getName(), 'legacy-service.');
return [
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:8192'],
'ai_declaration' => [
Rule::requiredIf(! $isLegacyServiceCall),
'nullable',
Rule::enum(AiImageDeclaration::class),
],
'title' => ['nullable', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:500'],
'copyright' => ['nullable', 'string', 'max:255'],

View file

@ -17,9 +17,12 @@ class PressReleaseImageResource extends JsonResource
$variants = is_array($this->variants) ? $this->variants : [];
$variantUrls = [];
// Variant-URLs über das Model auflösen, damit sie – wie die Original-URL –
// je nach Disk entweder direkt (public) oder über die geschützte
// Media-Route (local) zeigen. Sonst 404 für private Medien.
foreach ($variants as $key => $relativePath) {
if (is_string($relativePath) && filled($relativePath)) {
$variantUrls[$key] = asset('storage/'.ltrim($relativePath, '/'));
if (is_string($relativePath) && filled($relativePath) && filled($url = $this->variantUrl($key))) {
$variantUrls[$key] = $url;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Responses;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\FailedTwoFactorLoginResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Ersetzt Fortifys FailedTwoFactorLoginResponse: Der Standard leitet auf die
* Fortify-Route `two-factor.login` um, die bei `views => false` nicht
* registriert ist (RouteNotFoundException). Wir leiten stattdessen zurück auf
* unsere Volt-Challenge-Seite.
*/
class FailedTwoFactorChallengeResponse implements FailedTwoFactorLoginResponse
{
public function toResponse($request): Response
{
[$key, $message] = $request->filled('recovery_code')
? ['recovery_code', __('Der eingegebene Wiederherstellungs-Code ist ungültig.')]
: ['code', __('Der eingegebene Code ist ungültig. Bitte versuchen Sie es mit einem aktuellen Code erneut.')];
if ($request->wantsJson()) {
throw ValidationException::withMessages([
$key => [$message],
]);
}
return redirect()->route('two-factor.challenge')->withErrors([$key => $message]);
}
}

View file

@ -8,6 +8,7 @@ use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
@ -80,6 +81,13 @@ class ClassifyPressRelease implements ShouldQueue
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText());
} catch (BlacklistViolationException) {
// publish() hat die PM bereits abgelehnt und den Autor benachrichtigt.
} catch (QuotaExceededException $exception) {
// Kontingent zwischen Einreichung und KI-Freigabe aufgebraucht: PM
// bleibt in Review, damit Autor/Admin bewusst entscheiden — kein
// Job-Retry, der die Klassifikation sinnlos wiederholt.
Log::info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [
'press_release_id' => $pressRelease->id,
]);
}
}

View file

@ -57,7 +57,7 @@ class ScorePressRelease implements ShouldQueue
'model' => $result->model,
'result' => (string) $result->score,
'reason' => $tier->label(),
'raw_response' => $result->rawResponse,
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
'created_at' => now(),
]);
}

View file

@ -2,18 +2,33 @@
namespace App\Listeners;
use App\Enums\ActivityType;
use App\Enums\EmailNotificationType;
use App\Enums\InvoiceStatus;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Mail\BookingPaymentConfirmed;
use App\Mail\PressReleaseDepublished;
use App\Models\CreditTopup;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Billing\CreditTopupService;
use App\Services\Billing\InvoiceNumberGenerator;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Cashier\Subscription;
/**
* Verarbeitet Stripe-Webhooks über das Cashier-Event (Phase 9E).
@ -32,17 +47,151 @@ use Laravel\Cashier\Events\WebhookReceived;
*/
class ProcessStripeWebhook
{
public function __construct(private readonly InvoiceNumberGenerator $numbers) {}
public function __construct(
private readonly InvoiceNumberGenerator $numbers,
private readonly ActivityLog $activityLog,
) {}
public function handle(WebhookReceived $event): void
{
match ($event->payload['type'] ?? null) {
'invoice.payment_succeeded' => $this->mirrorPaidInvoice($event->payload['data']['object'] ?? []),
'checkout.session.completed' => $this->fulfillCheckout($event->payload['data']['object'] ?? []),
'checkout.session.completed',
'checkout.session.async_payment_succeeded' => $this->fulfillCheckout($event->payload['data']['object'] ?? []),
'checkout.session.async_payment_failed' => $this->logFailedAsyncPayment($event->payload['data']['object'] ?? []),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($event->payload['data'] ?? []),
'customer.subscription.deleted' => $this->recordSubscriptionEnded($event->payload['data']['object'] ?? []),
default => null,
};
}
/**
* @param array<string, mixed> $data
*/
private function handleSubscriptionUpdated(array $data): void
{
$this->clearAppliedScheduledChange($data['object'] ?? []);
$this->recordCancellationChange($data);
}
/**
* Kündigung zum Periodenende (bzw. deren Rücknahme) im Stripe-Kundenportal:
* Cashier pflegt `ends_at`, hier kommt der Eintrag in den Aktivitäts-Verlauf.
* Die Transition steckt in `previous_attributes.cancel_at_period_end`.
*
* @param array<string, mixed> $data
*/
private function recordCancellationChange(array $data): void
{
$subscription = $data['object'] ?? [];
$previous = $data['previous_attributes'] ?? [];
if (! array_key_exists('cancel_at_period_end', $previous)) {
return;
}
$cancelled = (bool) ($subscription['cancel_at_period_end'] ?? false);
if ($cancelled === (bool) $previous['cancel_at_period_end']) {
return;
}
$user = Cashier::findBillable($subscription['customer'] ?? null);
if (! $user instanceof User) {
return;
}
$endsAt = filled($subscription['cancel_at'] ?? null)
? Carbon::createFromTimestamp((int) $subscription['cancel_at'])->timezone(config('app.timezone'))
: null;
$description = $cancelled
? sprintf(
'Abo gekündigt — läuft am %s aus. %s (%s)',
$endsAt?->format('d.m.Y') ?? 'Periodenende',
$user->name,
$user->email,
)
: sprintf('Abo-Kündigung zurückgenommen. %s (%s)', $user->name, $user->email);
$this->activityLog->record(
ActivityType::SubscriptionChange,
$description,
user: $user,
subject: $this->localSubscription($subscription['id'] ?? null),
meta: array_filter([
'stripe_subscription_id' => $subscription['id'] ?? null,
'cancel_at' => $endsAt?->toDateTimeString(),
'cancelled' => $cancelled,
], fn ($value): bool => $value !== null),
);
}
/**
* Abo endgültig beendet (Periodenende erreicht oder Sofort-Kündigung).
*
* @param array<string, mixed> $subscription
*/
private function recordSubscriptionEnded(array $subscription): void
{
$user = Cashier::findBillable($subscription['customer'] ?? null);
if (! $user instanceof User) {
return;
}
$this->activityLog->record(
ActivityType::SubscriptionChange,
sprintf('Abo beendet. %s (%s)', $user->name, $user->email),
user: $user,
subject: $this->localSubscription($subscription['id'] ?? null),
meta: array_filter(['stripe_subscription_id' => $subscription['id'] ?? null]),
);
}
private function localSubscription(?string $stripeId): ?Subscription
{
return $stripeId
? Subscription::query()->where('stripe_id', $stripeId)->first()
: null;
}
/**
* Vorgemerkter Tarifwechsel (Downgrade zum Periodenende): Sobald der
* Stripe Subscription Schedule die neue Phase aktiviert, meldet
* `customer.subscription.updated` den neuen Preis — die lokale
* Vormerkung ist damit erledigt. Cashiers eigener Webhook-Handler
* aktualisiert `stripe_price` im selben Request.
*
* @param array<string, mixed> $stripeSubscription
*/
private function clearAppliedScheduledChange(array $stripeSubscription): void
{
$stripeId = $stripeSubscription['id'] ?? null;
$price = $stripeSubscription['items']['data'][0]['price']['id'] ?? null;
if (! $stripeId || ! $price) {
return;
}
$cleared = Subscription::query()
->where('stripe_id', $stripeId)
->where('scheduled_stripe_price', $price)
->update([
'scheduled_stripe_price' => null,
'scheduled_change_at' => null,
'stripe_schedule_id' => null,
]);
if ($cleared > 0) {
Log::info('Vorgemerkter Tarifwechsel wurde von Stripe vollzogen.', [
'stripe_subscription_id' => $stripeId,
'stripe_price' => $price,
]);
}
}
/**
* @param array<string, mixed> $stripeInvoice
*/
@ -54,7 +203,6 @@ class ProcessStripeWebhook
return;
}
// Idempotent: Stripe liefert Webhooks mindestens einmal.
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
return;
}
@ -71,28 +219,85 @@ class ProcessStripeWebhook
}
$subtotal = (int) ($stripeInvoice['subtotal'] ?? 0);
$tax = (int) ($stripeInvoice['tax'] ?? 0);
// Steuer je nach Stripe-API-Generation: `tax` (alt), sonst Summe aus
// `total_taxes` (aktuell) bzw. `total_tax_amounts` (Übergangsname).
$tax = (int) ($stripeInvoice['tax']
?? collect($stripeInvoice['total_taxes'] ?? $stripeInvoice['total_tax_amounts'] ?? [])->sum('amount'));
$total = (int) ($stripeInvoice['total'] ?? $subtotal + $tax);
$invoice = Invoice::query()->create([
'user_id' => $user->id,
'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id,
'number' => $this->numbers->nextStripeNumber(),
'status' => InvoiceStatus::Paid->value,
'amount_cents' => $subtotal,
'tax_cents' => $tax,
'total_cents' => $total,
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? 'eur')),
'is_netto' => $tax === 0,
'invoice_date' => now()->toDateString(),
'paid_at' => now(),
'stripe_invoice_id' => $stripeInvoiceId,
]);
// Ein Tarifwechsel nach unten erzeugt eine Proration-Gutschrift:
// Stripe meldet sie als "bezahlte" Rechnung mit negativem Betrag,
// das Guthaben verbleibt als Kundenkonto-Saldo und mindert künftige
// Rechnungen. In den STR-Rechnungsausgang gehört so ein Beleg nicht
// (QA-Befund 07.07.: unsigned-Spalten → Webhook-500) — der Beleg
// selbst bleibt bei Stripe einsehbar.
if ($total <= 0) {
Log::info('STR-Spiegelung übersprungen: Stripe-Beleg ohne positiven Rechnungsbetrag (Gutschrift/Null-Rechnung).', [
'stripe_invoice_id' => $stripeInvoiceId,
'total_cents' => $total,
]);
return;
}
try {
$invoice = DB::transaction(function () use ($user, $stripeInvoice, $stripeInvoiceId, $subtotal, $tax, $total): ?Invoice {
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
return null;
}
return Invoice::query()->create([
'user_id' => $user->id,
'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id,
'number' => $this->numbers->nextStripeNumber(),
'status' => InvoiceStatus::Paid->value,
'amount_cents' => $subtotal,
'tax_cents' => $tax,
'total_cents' => $total,
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? config('billing.default_currency'))),
'is_netto' => $tax === 0,
'invoice_date' => now()->toDateString(),
'paid_at' => now(),
'stripe_invoice_id' => $stripeInvoiceId,
]);
});
} catch (UniqueConstraintViolationException $exception) {
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
return;
}
throw $exception;
}
if ($invoice === null) {
return;
}
Log::info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [
'number' => $invoice->number,
'stripe_invoice_id' => $stripeInvoiceId,
]);
// Zahlungsbestätigung an den Kunden — genau eine pro Beleg, da die
// Spiegelung über stripe_invoice_id idempotent ist. Deckt alle
// Buchungsarten ab (Tarif inkl. Verlängerung, Einzel-PM, Credits).
if ($user->email) {
Mail::to($user->email)->queue(new BookingPaymentConfirmed($user, $invoice));
}
$this->activityLog->record(
ActivityType::BookingPaid,
sprintf(
'Zahlung eingegangen: Rechnung %s über %s € — %s (%s).',
$invoice->number,
number_format($invoice->total_cents / 100, 2, ',', '.'),
$user->name,
$user->email,
),
user: $user,
subject: $invoice,
meta: ['invoice_number' => $invoice->number, 'total_cents' => $invoice->total_cents],
);
}
/**
@ -127,10 +332,40 @@ class ProcessStripeWebhook
*/
private function fulfillCheckout(array $session): void
{
// Asynchrone Zahlarten (PayPal, SEPA …): `checkout.session.completed`
// feuert dort schon nach dem Redirect mit payment_status=unpaid — das
// Ergebnis kommt erst mit `checkout.session.async_payment_succeeded/
// _failed`. Erfüllt wird deshalb nur bei bestätigter Zahlung; beide
// Erfüllungspfade sind idempotent, doppelte Events bleiben folgenlos.
if (! in_array($session['payment_status'] ?? null, ['paid', 'no_payment_required'], true)) {
Log::info('Checkout-Session noch unbezahlt — Erfüllung wartet auf async_payment_succeeded.', [
'session_id' => $session['id'] ?? null,
'payment_status' => $session['payment_status'] ?? null,
]);
return;
}
$this->fulfillSinglePurchase($session);
$this->fulfillCreditTopup($session);
}
/**
* Fehlgeschlagene asynchrone Zahlung: Kauf/Topup bleibt bewusst auf
* Pending (nichts wurde erfüllt, der Kunde kann erneut buchen) — aber
* die Betriebsspur muss sichtbar sein.
*
* @param array<string, mixed> $session
*/
private function logFailedAsyncPayment(array $session): void
{
Log::warning('Asynchrone Stripe-Zahlung fehlgeschlagen — keine Erfüllung.', [
'session_id' => $session['id'] ?? null,
'single_purchase_id' => $session['metadata']['single_purchase_id'] ?? null,
'credit_topup_id' => $session['metadata']['credit_topup_id'] ?? null,
]);
}
/**
* @param array<string, mixed> $session
*/
@ -168,19 +403,102 @@ class ProcessStripeWebhook
return;
}
$purchase = SinglePurchase::query()->find((int) $purchaseId);
/** @var array{purchase: SinglePurchase, marked_paid: bool, depublished_release: PressRelease|null}|null $result */
$result = DB::transaction(function () use ($purchaseId, $session): ?array {
$purchase = SinglePurchase::query()
->whereKey((int) $purchaseId)
->lockForUpdate()
->first();
if (! $purchase || $purchase->status !== SinglePurchaseStatus::Pending) {
if (! $purchase) {
return null;
}
$markedPaid = false;
if ($purchase->status === SinglePurchaseStatus::Pending) {
$purchase->update([
'status' => SinglePurchaseStatus::Paid->value,
'paid_at' => now(),
'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id,
'stripe_payment_intent_id' => $session['payment_intent'] ?? null,
]);
$markedPaid = true;
} elseif (
$purchase->type !== SinglePurchaseType::Depublication
|| $purchase->status !== SinglePurchaseStatus::Paid
) {
return null;
}
return [
'purchase' => $purchase,
'marked_paid' => $markedPaid,
'depublished_release' => $this->fulfillDepublication($purchase),
];
});
if ($result === null) {
return;
}
$purchase = $result['purchase'];
if ($result['marked_paid']) {
Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
}
$pressRelease = $result['depublished_release'];
if ($pressRelease === null) {
return;
}
$buyer = $purchase->user;
if ($buyer && $buyer->email && $buyer->wantsEmailNotification(EmailNotificationType::PressReleaseDepublished)) {
Mail::to($buyer->email)->queue(new PressReleaseDepublished($buyer, $pressRelease));
}
Log::info('Pressemitteilung depubliziert.', [
'single_purchase_id' => $purchase->id,
'press_release_id' => $pressRelease->id,
]);
}
/**
* T12: Nach bezahltem Depublikations-Kauf die PM sofort aus der
* öffentlichen Ansicht nehmen (Tombstone bleibt). Replays sind über den
* Pending-Status des Kaufs abgefangen; der Kauf gilt mit der
* Depublikation als eingelöst.
*/
private function fulfillDepublication(SinglePurchase $purchase): ?PressRelease
{
if ($purchase->type !== SinglePurchaseType::Depublication) {
return null;
}
$pressRelease = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($purchase->press_release_id)
->lockForUpdate()
->first();
if (! $pressRelease || $pressRelease->status !== PressReleaseStatus::Published) {
Log::warning('Depublikation bezahlt, aber PM nicht (mehr) veröffentlicht.', [
'single_purchase_id' => $purchase->id,
'press_release_id' => $purchase->press_release_id,
]);
return null;
}
app(PressReleaseService::class)->depublish($pressRelease);
$purchase->update([
'status' => SinglePurchaseStatus::Paid->value,
'paid_at' => now(),
'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id,
'stripe_payment_intent_id' => $session['payment_intent'] ?? null,
'status' => SinglePurchaseStatus::Consumed->value,
'consumed_at' => now(),
]);
Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
return $pressRelease;
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace App\Listeners;
use App\Enums\ActivityType;
use App\Models\User;
use App\Services\ActivityLog;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Registered;
/**
* Protokolliert Registrierungen und Logins im Aktivitäts-Verlauf.
*
* Der Anmeldeweg kommt als Request-Attribut `auth_method` von der jeweils
* einloggenden Stelle (Magic-Link-/Google-Controller, Impersonation);
* ohne Attribut ist es der Fortify-Passwort-Login.
*
* Die Methoden heißen bewusst NICHT `handle*`: Laravels Event-Discovery
* würde sie sonst zusätzlich zur manuellen Verdrahtung im
* AppServiceProvider registrieren (doppelte Einträge).
*/
class RecordAuthActivity
{
public const AUTH_METHOD_ATTRIBUTE = 'auth_method';
private const METHOD_LABELS = [
'password' => 'Passwort',
'magic-link' => 'Magic-Link',
'google' => 'Google',
'impersonation' => 'Admin-Impersonation',
];
public function __construct(private readonly ActivityLog $activityLog) {}
public function recordRegistered(Registered $event): void
{
$user = $event->user;
if (! $user instanceof User) {
return;
}
$this->activityLog->record(
ActivityType::UserRegistered,
sprintf('%s (%s) hat sich registriert — Formular.', $user->name, $user->email),
user: $user,
meta: ['way' => 'form'],
);
}
public function recordLogin(Login $event): void
{
$user = $event->user;
if (! $user instanceof User) {
return;
}
$method = (string) request()->attributes->get(self::AUTH_METHOD_ATTRIBUTE, 'password');
$this->activityLog->record(
ActivityType::UserLoggedIn,
sprintf(
'%s (%s) hat sich angemeldet — %s.',
$user->name,
$user->email,
self::METHOD_LABELS[$method] ?? $method,
),
user: $user,
meta: ['method' => $method],
);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\PressRelease;
/**
* Gemeinsame Helfer der Admin-PM-Komponenten (Index/Edit/Show).
*
* Admins arbeiten portalübergreifend, daher werden Pressemitteilungen ohne
* globale Scopes geladen. Zentralisiert den zuvor ~15x duplizierten Lookup.
*/
trait HandlesAdminPressReleaseActions
{
private function findAdminPR(int $id): PressRelease
{
return PressRelease::withoutGlobalScopes()->findOrFail($id);
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Livewire\Concerns;
use Illuminate\Support\Facades\DB;
/**
* Gemeinsame Heuristik, ob für einen Suchbegriff der DB-Volltextindex genutzt
* werden kann: ab drei Zeichen und nur auf MySQL/PostgreSQL (SQLite im Test
* fällt auf die LIKE-Suche zurück). Single Source of Truth für die Admin-Listen
* und Firmen-Comboboxen.
*/
trait HasFullTextSearch
{
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Livewire\Concerns;
/**
* Spalten-Sortierung für Admin-Tabellen. Erwartet die öffentlichen Properties
* `$sortBy`/`$sortDir` (deren Defaults bleiben bewusst in der jeweiligen
* Komponente, da sie je Tabelle divergieren) sowie `resetPage()` aus
* `Livewire\WithPagination`.
*/
trait HasSortableTable
{
public function sort(string $column): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDir = 'asc';
}
$this->resetPage();
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Concerns;
use App\Enums\AiTextDeclaration;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
/**
* KI-Kennzeichnung im Submit-Flow (Art. 50 KI-VO, Decision-Update 08.07.2026).
*
* Gemeinsame Properties + Validierung der vier Customer-Submit-Pfade
* (Erstellen, Bearbeiten, Detailansicht, Liste). Die Selbst-Deklaration ist
* Pflicht bei JEDEM Einreichen: ohne KI-Grad des Textes und verbindliche
* Bestätigung kein Submit. Die Bestätigung wird nicht vorbelegt — sie muss
* bei jedem Submit erneut gesetzt werden (Audit-Trail pro Einreichung).
*/
trait InteractsWithAiDeclaration
{
public ?string $aiTextDeclaration = null;
public bool $aiDeclarationConfirmed = false;
/**
* Pflichtprüfung vor dem Einreichen — Fehler erscheinen direkt im
* Submit-Modal. Entwurf-Speichern bleibt frei von dieser Pflicht.
*/
protected function validateAiDeclaration(): void
{
$this->validate([
'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::values())],
'aiDeclarationConfirmed' => ['accepted'],
], [
'aiTextDeclaration.required' => __('Bitte angeben, ob und wie generative KI beim Text eingesetzt wurde.'),
'aiDeclarationConfirmed.accepted' => __('Bitte bestätigen, dass die Angaben zum KI-Einsatz vollständig und wahrheitsgemäß sind.'),
]);
}
/**
* Spaltenwerte für die PM-Persistenz. Der Bestätigungszeitpunkt wird nur
* gestempelt, wenn die Checkbox tatsächlich gesetzt wurde.
*
* @return array{ai_text_declaration: ?string, ai_declaration_confirmed_at: ?Carbon}
*/
protected function aiDeclarationAttributes(): array
{
return [
'ai_text_declaration' => $this->aiTextDeclaration,
'ai_declaration_confirmed_at' => $this->aiDeclarationConfirmed ? now() : null,
];
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Contact;
use Illuminate\Database\Eloquent\Collection;
/**
* Pressekontakt-Helfer der PM-Editoren (Admin + Customer).
*
* Alle vier Editoren lesen die Kontakte einer Firma identisch über den
* `Contact::forCompany()`-Scope (ohne globale Scopes, sortiert nach Name).
* Single Source of Truth für die Kontaktauflösung im Editor.
*/
trait InteractsWithPressReleaseContacts
{
/**
* @return Collection<int, Contact>
*/
private function companyContacts(int $companyId): Collection
{
if ($companyId <= 0) {
return new Collection;
}
return Contact::forCompany($companyId)
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']);
}
private function companyContact(int $contactId, int $companyId): ?Contact
{
if ($contactId <= 0 || $companyId <= 0) {
return null;
}
return Contact::forCompany($companyId)->whereKey($contactId)->first();
}
private function defaultContactIdFor(int $companyId): ?int
{
if ($companyId <= 0) {
return null;
}
return $this->companyContacts($companyId)->first()?->id;
}
}

View file

@ -0,0 +1,155 @@
<?php
namespace App\Livewire\Concerns;
use Carbon\Carbon;
use Flux\Flux;
use Illuminate\Validation\ValidationException;
/**
* Termin-/Scheduling-Logik der PM-Editoren (Admin + Customer, Erstellen +
* Bearbeiten). Single Source of Truth für die Veröffentlichungsplanung.
*
* Die nutzende Livewire-Komponente muss bereitstellen:
* - string $publishMode ('now' | 'scheduled')
* - ?string $scheduledDate ('Y-m-d')
* - ?string $scheduledTime ('H:i')
* - ?string $scheduledAt ('Y-m-dTH:i', abgeleitet aus Datum + Zeit)
* - array formRules() Validierungsregeln (enthält 'scheduledAt')
*/
trait InteractsWithPressReleaseScheduling
{
public function updatedPublishMode(): void
{
$this->syncScheduledAt();
if ($this->publishMode === 'now') {
$this->scheduledDate = null;
$this->scheduledTime = null;
$this->scheduledAt = null;
$this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']);
}
}
public function updatedScheduledDate(): void
{
$this->syncScheduledAt();
$this->validateScheduledAtWhenReady();
}
public function updatedScheduledTime(): void
{
$this->syncScheduledAt();
$this->validateScheduledAtWhenReady();
}
/**
* Live-Re-Validation: sobald für ein Property bereits ein Error im Bag
* liegt, wird es bei jeder Änderung neu geprüft. Die Termin-Synchronisierung
* liegt vollständig in den spezifischen `updated{PublishMode,ScheduledDate,
* ScheduledTime}`-Hooks; hier bleibt nur die generische Re-Validierung.
*/
public function updated(string $property): void
{
if (! $this->getErrorBag()->has($property)) {
return;
}
try {
$this->validateOnly($property, $this->formRules());
} catch (ValidationException) {
// Feld bleibt invalid — Error-Bag wird automatisch befüllt.
}
}
protected function syncScheduledAt(): void
{
if ($this->publishMode !== 'scheduled') {
$this->scheduledAt = null;
return;
}
if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) {
$scheduledAt = Carbon::parse($this->scheduledAt);
$this->scheduledDate = $scheduledAt->format('Y-m-d');
$this->scheduledTime = $scheduledAt->format('H:i');
return;
}
if (blank($this->scheduledDate) || blank($this->scheduledTime)) {
$this->scheduledAt = null;
return;
}
$this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}";
}
/**
* Parst den erfassten Termin in der Anwendungs-Zeitzone (Europe/Berlin).
* Null, wenn kein Termin gesetzt ist.
*/
protected function resolveScheduledAt(): ?Carbon
{
if (blank($this->scheduledAt)) {
return null;
}
return Carbon::parse($this->scheduledAt);
}
protected function validateScheduledAtWhenReady(): void
{
if (blank($this->scheduledAt)) {
return;
}
$this->resetErrorBag('scheduledAt');
$scheduledAt = $this->resolveScheduledAt();
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
$this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
return;
}
try {
$this->validateOnly('scheduledAt', $this->formRules());
} catch (ValidationException) {
// Termin bleibt invalid — Error-Bag wird automatisch befüllt.
}
}
/**
* Toast mit Sammelhinweis nach fehlgeschlagener Validierung. Nennt die
* betroffenen Felder beim Namen (aus validation.attributes), damit der
* User nicht raten muss, wo es hakt. Die einzelnen Feld-Errors werden
* weiterhin direkt am Input angezeigt, zusätzlich rendert
* [[x-portal.pr-error-summary]] eine persistente Übersicht im Formular.
*/
protected function notifyValidationError(?ValidationException $exception = null): void
{
$fieldKeys = $exception
? array_keys($exception->errors())
: $this->getErrorBag()->keys();
$fieldLabels = collect($fieldKeys)
->map(fn (string $key): string => trans()->has('validation.attributes.'.$key)
? __('validation.attributes.'.$key)
: $key)
->unique()
->values();
Flux::toast(
heading: __('Bitte Eingaben prüfen'),
text: $fieldLabels->isNotEmpty()
? __('Bitte prüfe: :fields', ['fields' => $fieldLabels->join(', ')])
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
variant: 'danger',
duration: 8000,
);
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Company;
/**
* Themen-Tag-Verwaltung für die PM-Editoren (Erstellen/Bearbeiten).
*
* Tags werden als kommaseparierte Liste in der string-Property `$keywords`
* gehalten (max. 5, eindeutig). Die nutzende Livewire-Komponente muss eine
* `$keywords`-Property sowie die Computed-Properties `tags` und
* `presubmitChecks` bereitstellen — diese werden nach jeder Änderung
* invalidiert.
*/
trait InteractsWithPressReleaseTags
{
public function addTag(string $tag): void
{
$tag = trim($tag);
if ($tag === '') {
return;
}
$existing = $this->tagsArray();
if (count($existing) >= 5) {
return;
}
if (in_array($tag, $existing, true)) {
return;
}
$existing[] = $tag;
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
public function removeTag(string $tag): void
{
$existing = array_values(array_filter(
$this->tagsArray(),
fn (string $existingTag): bool => $existingTag !== $tag,
));
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
/**
* @return list<string>
*/
private function tagsArray(): array
{
if (trim($this->keywords) === '') {
return [];
}
return collect(explode(',', $this->keywords))
->map(fn (string $tag): string => trim($tag))
->filter()
->unique()
->values()
->all();
}
/**
* Tag-Vorschläge: Portal-Label und Länderbezug der Firma plus generische
* Defaults. Identisch in allen vier PM-Editoren.
*
* @return list<string>
*/
private function tagSuggestionsFor(?Company $company): array
{
$defaults = [
__('Mittelstand'),
__('Unternehmen'),
__('Eröffnung'),
__('Innovation'),
__('Nachhaltigkeit'),
];
if (! $company) {
return $defaults;
}
return array_values(array_unique(array_filter([
$company->portal?->label(),
$company->country_code === 'DE' ? __('Deutschland') : null,
...$defaults,
])));
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Concerns;
use Closure;
use Illuminate\Validation\Rule;
/**
* Gemeinsame Validierungsregeln der PM-Editoren (Admin + Customer).
*
* Liefert alle in allen vier Editoren identischen Regeln (Inhaltsfelder,
* Veröffentlichungsmodus, terminabhängiger Scheduling-Block, Embargo). Die
* nutzende Komponente ergänzt in `formRules()` nur die unterschiedlichen
* Schlüssel (`portal` nur im Admin, `companyId` mit/ohne `exists`).
*
* Setzt [[InteractsWithPressReleaseScheduling]] voraus (nutzt `$publishMode`
* und `resolveScheduledAt()`).
*
* @return array<string, array<int, mixed>>
*/
trait ProvidesPressReleaseFormRules
{
/**
* @return array<string, array<int, mixed>>
*/
protected function baseFormRules(): array
{
$rules = [
'language' => ['required', Rule::in(['de', 'en'])],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
'contactId' => ['nullable', 'integer'],
'title' => ['required', 'string', 'min:5', 'max:255'],
'subtitle' => ['nullable', 'string', 'max:255'],
'text' => ['required', 'string', 'min:50'],
'keywords' => ['nullable', 'string', 'max:255'],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
'publishMode' => ['required', Rule::in(['now', 'scheduled'])],
];
// Termin-Pflicht nur, wenn der User explizit Scheduling gewählt hat.
// Min. 5 Minuten in der Zukunft, damit der Background-Job (alle 5 Min)
// die PM verlässlich rechtzeitig fängt.
if ($this->publishMode === 'scheduled') {
$rules['scheduledDate'] = ['required', 'date'];
$rules['scheduledTime'] = ['required', 'date_format:H:i'];
$rules['scheduledAt'] = [
'required',
'date',
// Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen-
// bewusst prüfen statt über die naive `after:`-Regel.
function (string $attribute, mixed $value, Closure $fail): void {
$scheduledAt = $this->resolveScheduledAt();
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
$fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
}
},
];
} else {
$rules['scheduledDate'] = ['nullable'];
$rules['scheduledTime'] = ['nullable'];
$rules['scheduledAt'] = ['nullable'];
}
$rules['embargoAt'] = ['nullable'];
return $rules;
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Livewire\Concerns;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use Livewire\Attributes\Computed;
/**
* Kanonische Pre-Submit-Checkliste der PM-Editoren (Admin + Customer).
*
* Vorher in allen vier Editoren dupliziert und leicht auseinandergedriftet
* (Firmenname vs. leer, trans_choice vs. statischer Text). Diese Version ist
* die Single Source of Truth.
*
* Die nutzende Komponente muss bereitstellen: die Properties `$title`,
* `$text`, `$companyId`, `$categoryId`, `$contactId`, die Tag-Helfer aus
* [[InteractsWithPressReleaseTags]] (`tagsArray()`) sowie `selectedCompany()`.
*/
trait ProvidesPressReleasePresubmitChecks
{
/**
* @return array<int, array{key: string, status: string, label: string, sub: string}>
*/
#[Computed]
public function presubmitChecks(): array
{
$titleLen = mb_strlen(trim($this->title));
$textLen = app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text);
$tagsCount = count($this->tagsArray());
return [
[
'key' => 'title',
'status' => $titleLen >= 5 ? 'ok' : 'err',
'label' => __('Titel vorhanden'),
'sub' => $titleLen > 0 ? __(':n Zeichen', ['n' => $titleLen]) : __('Noch leer'),
],
[
'key' => 'text',
'status' => $textLen >= 600 ? 'ok' : ($textLen >= 50 ? 'warn' : 'err'),
'label' => __('Mindestlänge Fließtext erreicht'),
'sub' => __(':n / 600 Zeichen empfohlen', ['n' => number_format($textLen, 0, ',', '.')]),
],
[
'key' => 'company',
'status' => $this->companyId ? 'ok' : 'err',
'label' => __('Firma zugeordnet'),
'sub' => $this->selectedCompany()?->name ?? __('Keine Firma gewählt'),
],
[
'key' => 'category',
'status' => $this->categoryId ? 'ok' : 'err',
'label' => __('Kategorie gewählt'),
'sub' => $this->categoryId ? '' : __('Kategorie ist Pflicht'),
],
[
'key' => 'contact',
'status' => $this->contactId ? 'ok' : 'warn',
'label' => __('Pressekontakt zugeordnet'),
'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'),
],
[
'key' => 'tags',
'status' => $tagsCount >= 1 ? 'ok' : 'warn',
'label' => __('Themen-Tags vergeben'),
'sub' => $tagsCount >= 1
? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount])
: __('empfohlen für SEO & Auffindbarkeit'),
],
];
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Livewire\Concerns;
use App\Enums\Portal;
use App\Enums\RegistrationType;
use Illuminate\Validation\Rule;
/**
* Gemeinsame Validierungsregeln der Benutzer-Formulare (Anlegen/Bearbeiten).
* Nur die zwischen beiden Formularen identischen Blöcke leben hier; die rein
* im Bearbeiten genutzten Profil-/Kontakt-Regeln bleiben dort inline.
*/
trait ProvidesUserFormRules
{
/**
* Identitäts-Stammdaten. Beim Bearbeiten ignoriert die E-Mail-Unique-Regel
* den eigenen Datensatz.
*
* @return array<string, mixed>
*/
protected function userIdentityRules(?int $ignoreUserId = null): array
{
$emailUnique = Rule::unique('users', 'email');
if ($ignoreUserId !== null) {
$emailUnique = $emailUnique->ignore($ignoreUserId);
}
return [
'name' => ['required', 'string', 'max:255'],
'agencyName' => ['nullable', 'string', 'max:190'],
'email' => ['required', 'email', $emailUnique],
'portal' => ['required', Rule::in(array_map(static fn (Portal $portal): string => $portal->value, Portal::cases()))],
'registrationType' => ['required', Rule::in(array_map(static fn (RegistrationType $type): string => $type->value, RegistrationType::cases()))],
'language' => ['required', 'string', Rule::in(['de', 'en'])],
];
}
/**
* Rollen-Auswahl plus Firmen-Verknüpfungen mit Pivot-Rolle.
*
* @return array<string, mixed>
*/
protected function userRolesAndCompaniesRules(): array
{
return [
'selectedRoles' => ['required', 'array', 'min:1'],
'selectedRoles.*' => ['string', Rule::exists('roles', 'name')],
'linkedCompanyIds' => ['array'],
'linkedCompanyIds.*' => ['integer', Rule::exists('companies', 'id')],
'companyRoles' => ['array'],
'companyRoles.*' => ['string', Rule::in(['member', 'responsible', 'owner'])],
];
}
/**
* Rechnungsadresse (optional; nur gespeichert, wenn vollständig).
*
* @return array<string, mixed>
*/
protected function userBillingRules(): array
{
return [
'billing.salutation_key' => ['nullable', 'string', 'max:20'],
'billing.title' => ['nullable', 'string', 'max:80'],
'billing.name' => ['nullable', 'string', 'max:255'],
'billing.address1' => ['nullable', 'string', 'max:255'],
'billing.address2' => ['nullable', 'string', 'max:255'],
'billing.postal_code' => ['nullable', 'string', 'max:20'],
'billing.city' => ['nullable', 'string', 'max:120'],
'billing.country_code' => ['nullable', 'string', 'size:2'],
];
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Mail;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Zahlungs-/Buchungsbestätigung an den Kunden, sobald eine bezahlte
* Stripe-Rechnung in den STR-Kreis gespiegelt wurde — greift damit für
* jede Buchungsart (Tarif inkl. Verlängerung, Einzel-PM, Credits).
*
* Transaktionsmail: bewusst ohne Opt-out (analog Kündigungs-/
* Widerrufsbestätigungen, siehe EmailNotificationType-Doku).
*/
class BookingPaymentConfirmed extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public string $invoicesUrl;
public function __construct(
public readonly User $user,
public readonly Invoice $invoice,
) {
$this->invoicesUrl = route('me.invoices.index');
}
public function envelope(): Envelope
{
// Blindkopie an das Rechts-/Service-Postfach: interner Beleg über
// jede versendete Zahlungsbestätigung.
$bcc = (string) config('legal.contact_email');
return new Envelope(
subject: 'Zahlungsbestätigung für Ihre Buchung — Rechnung '.$this->invoice->number,
bcc: $bcc !== '' ? [$bcc] : [],
);
}
public function content(): Content
{
return new Content(view: 'emails.billing.payment-confirmed');
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Mail\Concerns;
use App\Enums\EmailNotificationType;
use App\Models\User;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Support\Facades\URL;
/**
* Macht eine Benachrichtigungs-Mail abbestellbar: signierter Abmelde-Link
* für den Mail-Footer (ohne Login nutzbar, ohne Ablauf — der Link aus alten
* Mails bleibt gültig) plus die List-Unsubscribe-Header nach RFC 8058, mit
* denen Mail-Clients (Gmail, Apple Mail …) einen nativen
* „Abbestellen"-Button anbieten.
*
* Der Link wird im Konstruktor der Mailable erzeugt (configureUnsubscribe),
* damit er beim Queuen mit serialisiert wird.
*/
trait Unsubscribable
{
public string $unsubscribeUrl;
public EmailNotificationType $notificationType;
protected function configureUnsubscribe(User $user, EmailNotificationType $type): void
{
$this->notificationType = $type;
$this->unsubscribeUrl = URL::signedRoute('email-preferences.unsubscribe', [
'user' => $user->id,
'type' => $type->value,
]);
}
public function headers(): Headers
{
return new Headers(text: [
'List-Unsubscribe' => '<'.$this->unsubscribeUrl.'>',
'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
]);
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ContactRequestSubmitted extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public readonly string $senderName,
public readonly string $senderEmail,
public readonly string $messageText,
public readonly string $host,
public readonly ?string $topicLabel = null,
public readonly ?string $pageUrl = null,
) {}
public function envelope(): Envelope
{
$subject = 'Neue Kontaktanfrage über '.$this->host;
if ($this->topicLabel !== null && $this->topicLabel !== 'Kontaktaufnahme') {
$subject = 'Neue '.$this->topicLabel.' über '.$this->host;
}
return new Envelope(
subject: $subject,
replyTo: [new Address($this->senderEmail, $this->senderName)],
);
}
public function content(): Content
{
return new Content(view: 'emails.contact.submitted');
}
}

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