Release: Go-Live Pressekonto (20.07.2026)
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:
commit
7b27ec5a23
1050 changed files with 42869 additions and 426978 deletions
156
.env.example
156
.env.example
|
|
@ -1,25 +1,67 @@
|
||||||
APP_NAME=Laravel
|
APP_NAME=Pressekonto
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=https://pressekonto.test
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=de
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=de
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
APP_MAINTENANCE_DRIVER=file
|
||||||
# APP_MAINTENANCE_STORE=database
|
# 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
|
PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
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_STACK=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
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_CONNECTION=sqlite
|
||||||
# DB_HOST=127.0.0.1
|
# DB_HOST=127.0.0.1
|
||||||
# DB_PORT=3306
|
# DB_PORT=3306
|
||||||
|
|
@ -27,11 +69,27 @@ DB_CONNECTION=sqlite
|
||||||
# DB_USERNAME=root
|
# DB_USERNAME=root
|
||||||
# DB_PASSWORD=
|
# 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_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
# Prod: true setzen (Cookies nur über HTTPS).
|
||||||
|
# SESSION_SECURE_COOKIE=true
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
|
|
@ -47,6 +105,7 @@ REDIS_HOST=127.0.0.1
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# Prod: echten Mailer konfigurieren — Default `log` versendet NICHTS.
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_SCHEME=null
|
MAIL_SCHEME=null
|
||||||
MAIL_HOST=127.0.0.1
|
MAIL_HOST=127.0.0.1
|
||||||
|
|
@ -63,6 +122,64 @@ AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
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
|
# 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
|
# Console EXAKT der Callback-URL der Portal-Domain entsprechen (Login läuft auf
|
||||||
|
|
@ -73,8 +190,29 @@ GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
GOOGLE_REDIRECT_URI=https://pressekonto.com/auth/google/callback
|
GOOGLE_REDIRECT_URI=https://pressekonto.com/auth/google/callback
|
||||||
|
|
||||||
# Abo-/Bestandsschutz-Gate (WS-5). Ist der Schalter aus, darf jeder einreichen
|
# Empfänger des öffentlichen Kontaktformulars je Brand. Ohne Wert fällt der
|
||||||
# (Dev-Default). Für den Launch auf true setzen: Einreichen zur Prüfung erfordert
|
# Versand auf MAIL_FROM_ADDRESS zurück. [EXTERN: echte Adressen eintragen]
|
||||||
# dann ein aktives Abo, einen bezahlten Einzelkauf oder Bestandsschutz
|
CONTACT_EMAIL_PRESSEKONTO=
|
||||||
# (grandfathered). Siehe User::hasActiveBooking().
|
CONTACT_EMAIL_PRESSEECHO=
|
||||||
BILLING_ENFORCE_BOOKING=false
|
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=
|
||||||
|
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -58,3 +58,10 @@ _work/
|
||||||
_storage/
|
_storage/
|
||||||
_businessportal24.com/
|
_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/
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Actions\Admin;
|
namespace App\Actions\Admin;
|
||||||
|
|
||||||
|
use App\Listeners\RecordAuthActivity;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
@ -20,7 +21,8 @@ class UserImpersonation
|
||||||
return $this->canInitiate($admin)
|
return $this->canInitiate($admin)
|
||||||
&& ! $admin->is($target)
|
&& ! $admin->is($target)
|
||||||
&& ! $this->isActive()
|
&& ! $this->isActive()
|
||||||
&& $this->canBeImpersonated($target);
|
&& $this->canBeImpersonated($target)
|
||||||
|
&& $this->mayImpersonateRank($admin, $target);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canBeImpersonated(User $target): bool
|
public function canBeImpersonated(User $target): bool
|
||||||
|
|
@ -28,6 +30,16 @@ class UserImpersonation
|
||||||
return $target->canAccessCustomer();
|
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
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
|
|
@ -49,8 +61,13 @@ class UserImpersonation
|
||||||
throw new AuthorizationException(__('Dieser Benutzer kann nicht im Panel angemeldet werden.'));
|
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()]);
|
session([self::SessionKey => $admin->getKey()]);
|
||||||
|
|
||||||
|
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'impersonation');
|
||||||
Auth::login($target);
|
Auth::login($target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +87,7 @@ class UserImpersonation
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'impersonation');
|
||||||
Auth::login($admin);
|
Auth::login($admin);
|
||||||
|
|
||||||
return $admin;
|
return $admin;
|
||||||
|
|
|
||||||
141
app/Authorization/Permission.php
Normal file
141
app/Authorization/Permission.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Console/Commands/BackfillPortalRole.php
Normal file
62
app/Console/Commands/BackfillPortalRole.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,9 +26,10 @@ class FixLegacyTimestamps extends Command
|
||||||
|
|
||||||
protected $description = 'Korrigiert created_at/updated_at auf importierten Datensätzen aus den Legacy-DBs.';
|
protected $description = 'Korrigiert created_at/updated_at auf importierten Datensätzen aus den Legacy-DBs.';
|
||||||
|
|
||||||
private const PORTAL_DB = [
|
/** Mapping: Portal → Legacy-Connection; der Schema-Name kommt aus deren Config (.env). */
|
||||||
'presseecho' => 'presseecho',
|
private const PORTAL_CONNECTIONS = [
|
||||||
'businessportal24' => 'businessportal',
|
'presseecho' => 'mysql_presseecho',
|
||||||
|
'businessportal24' => 'mysql_businessportal',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Mapping: neue Tabelle → (legacy_portal, legacy_db, legacy_table, legacy_created_col) */
|
/** 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');
|
$isDryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
$entities = $entityOpt === 'all' ? array_keys(self::ENTITY_CONFIG) : [$entityOpt];
|
$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) {
|
if ($isDryRun) {
|
||||||
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||||
|
|
@ -87,9 +88,9 @@ class FixLegacyTimestamps extends Command
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($portals as $portal) {
|
foreach ($portals as $portal) {
|
||||||
$legacyDb = self::PORTAL_DB[$portal] ?? null;
|
$legacyDb = $this->legacySchema($portal);
|
||||||
if (! $legacyDb) {
|
if (! $legacyDb) {
|
||||||
$this->error("Unbekanntes Portal: {$portal}");
|
$this->error("Unbekanntes Portal oder fehlende Legacy-DB-Config: {$portal}");
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +109,19 @@ class FixLegacyTimestamps extends Command
|
||||||
return self::SUCCESS;
|
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
|
private function fixEntity(array $config, string $portal, string $legacyDb, bool $isDryRun): int
|
||||||
{
|
{
|
||||||
$newTable = $config['new_table'];
|
$newTable = $config['new_table'];
|
||||||
|
|
|
||||||
|
|
@ -258,7 +258,7 @@ class GrandfatherLegacySubscriptions extends Command
|
||||||
'type' => 'recurring',
|
'type' => 'recurring',
|
||||||
// Katalogpreise sind netto (Entscheidung 12.06.2026).
|
// Katalogpreise sind netto (Entscheidung 12.06.2026).
|
||||||
'price_cents' => $candidate['legacy_conditions']['net_cents'],
|
'price_cents' => $candidate['legacy_conditions']['net_cents'],
|
||||||
'currency' => 'EUR',
|
'currency' => config('billing.default_currency'),
|
||||||
'interval' => 'yearly',
|
'interval' => 'yearly',
|
||||||
'is_hidden' => true,
|
'is_hidden' => true,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
82
app/Console/Commands/ImportLegacyApiKeys.php
Normal file
82
app/Console/Commands/ImportLegacyApiKeys.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/Console/Commands/ImportLegacyBillingAddresses.php
Normal file
78
app/Console/Commands/ImportLegacyBillingAddresses.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ use App\Services\Import\PressReleaseImporter;
|
||||||
use App\Services\Import\UserAssociationLinker;
|
use App\Services\Import\UserAssociationLinker;
|
||||||
use App\Services\Import\UserImporter;
|
use App\Services\Import\UserImporter;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy-Import-Master-Command (Phase 6 – Datenmigration).
|
* 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=categories
|
||||||
* php artisan legacy:import --source=all --step=link-associations ← NEU
|
* php artisan legacy:import --source=all --step=link-associations ← NEU
|
||||||
* php artisan legacy:import --source=presseecho --force
|
* php artisan legacy:import --source=presseecho --force
|
||||||
|
* php artisan legacy:import --source=presseecho --since=today --force ← Delta: nur heute Angelegtes/Geändertes
|
||||||
*
|
*
|
||||||
* Schritte (--step):
|
* Schritte (--step):
|
||||||
* categories – Kategorien + Translations (einmalig, beide Portale identisch)
|
* 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)}
|
{--step=all : Schritt (categories|users|companies|contacts|press-releases|link-associations|all)}
|
||||||
{--dry-run : Nichts schreiben, nur zählen}
|
{--dry-run : Nichts schreiben, nur zählen}
|
||||||
{--force : Bereits importierte Datensätze erneut verarbeiten}
|
{--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)}';
|
{--chunk-size=500 : Batch-Größe (Debugging)}';
|
||||||
|
|
||||||
protected $description = 'Legacy-Daten aus Presseecho / Businessportal24 in die neue DB importieren.';
|
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');
|
$isDryRun = (bool) $this->option('dry-run');
|
||||||
$isForce = (bool) $this->option('force');
|
$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];
|
$portals = $source === 'all' ? self::PORTALS : [$source];
|
||||||
$steps = $step === 'all' ? self::STEPS : [$step];
|
$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')) {
|
if (! $isDryRun && ! $this->option('force')) {
|
||||||
$this->warn('WICHTIG: Dieser Command schreibt Daten in die Produktions-DB.');
|
$this->warn('WICHTIG: Dieser Command schreibt Daten in die Produktions-DB.');
|
||||||
$this->line(' Portale: '.implode(', ', $portals));
|
$this->line(' Portale: '.implode(', ', $portals));
|
||||||
|
|
@ -86,7 +109,7 @@ class ImportLegacyData extends Command
|
||||||
foreach ($steps as $currentStep) {
|
foreach ($steps as $currentStep) {
|
||||||
// link-associations arbeitet auf der neuen DB → einmalig, portal-unabhängig
|
// link-associations arbeitet auf der neuen DB → einmalig, portal-unabhängig
|
||||||
if ($currentStep === 'link-associations') {
|
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)');
|
$this->info('▶ Schritt [link-associations] (beide Portale, neue DB)');
|
||||||
$start = microtime(true);
|
$start = microtime(true);
|
||||||
$result = app(UserAssociationLinker::class)->run($ctx);
|
$result = app(UserAssociationLinker::class)->run($ctx);
|
||||||
|
|
@ -100,7 +123,7 @@ class ImportLegacyData extends Command
|
||||||
$stepPortals = ($currentStep === 'categories') ? [$portals[0]] : $portals;
|
$stepPortals = ($currentStep === 'categories') ? [$portals[0]] : $portals;
|
||||||
|
|
||||||
foreach ($stepPortals as $portal) {
|
foreach ($stepPortals as $portal) {
|
||||||
$ctx = new ImportContext($portal, $isDryRun, $isForce);
|
$ctx = new ImportContext($portal, $isDryRun, $isForce, $since);
|
||||||
|
|
||||||
$this->info("▶ Schritt [{$currentStep}] Portal [{$portal}]");
|
$this->info("▶ Schritt [{$currentStep}] Portal [{$portal}]");
|
||||||
$start = microtime(true);
|
$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);
|
$total = round(microtime(true) - $totalStart, 1);
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
$this->info("Import abgeschlossen in {$total}s.");
|
$this->info("Import abgeschlossen in {$total}s.");
|
||||||
|
|
|
||||||
131
app/Console/Commands/MigrateLegacyAgencyNames.php
Normal file
131
app/Console/Commands/MigrateLegacyAgencyNames.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Console/Commands/PrivatizePressReleaseMedia.php
Normal file
54
app/Console/Commands/PrivatizePressReleaseMedia.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
||||||
use App\Enums\PressReleaseClassification;
|
use App\Enums\PressReleaseClassification;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
|
use App\Scopes\PortalScope;
|
||||||
use App\Services\PressRelease\BlacklistViolationException;
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
use App\Services\PressRelease\PressReleaseService;
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
@ -47,7 +48,7 @@ class PublishScheduledPressReleases extends Command
|
||||||
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
|
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
|
||||||
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
|
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
|
||||||
// Fallback in der manuellen Queue.
|
// Fallback in der manuellen Queue.
|
||||||
$candidates = PressRelease::withoutGlobalScopes()
|
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
|
||||||
->where('status', PressReleaseStatus::Review->value)
|
->where('status', PressReleaseStatus::Review->value)
|
||||||
->whereIn('classification', [
|
->whereIn('classification', [
|
||||||
PressReleaseClassification::Green->value,
|
PressReleaseClassification::Green->value,
|
||||||
|
|
|
||||||
36
app/Console/Commands/RefreshMarketData.php
Normal file
36
app/Console/Commands/RefreshMarketData.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,12 @@ use Illuminate\Console\Command;
|
||||||
/**
|
/**
|
||||||
* Setzt den monatlichen PM-Kontingent-Verbrauch aller User zurück.
|
* Setzt den monatlichen PM-Kontingent-Verbrauch aller User zurück.
|
||||||
*
|
*
|
||||||
* Temporärer Stub bis zum echten Tarif-/Credit-Modul. Läuft per Scheduler
|
* Fester Bestandteil des Tarif-Modells (geprüft R1.3, 03.07.2026): Die
|
||||||
* am Monatsanfang (siehe routes/console.php).
|
* 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
|
class ResetMonthlyPressReleaseQuota extends Command
|
||||||
{
|
{
|
||||||
|
|
|
||||||
119
app/Console/Commands/RetireAgencyCompanyType.php
Normal file
119
app/Console/Commands/RetireAgencyCompanyType.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
213
app/Console/Commands/SeedLegacyApiTestdata.php
Normal file
213
app/Console/Commands/SeedLegacyApiTestdata.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,20 @@ class SyncStripePlans extends Command
|
||||||
|
|
||||||
foreach (Plan::query()->active()->get() as $plan) {
|
foreach (Plan::query()->active()->get() as $plan) {
|
||||||
if ($plan->stripe_product_id && $plan->stripe_price_id_monthly && $plan->stripe_price_id_yearly) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +69,7 @@ class SyncStripePlans extends Command
|
||||||
|
|
||||||
if (! $productId) {
|
if (! $productId) {
|
||||||
$product = $stripe->products->create([
|
$product = $stripe->products->create([
|
||||||
'name' => $plan->name,
|
'name' => $this->stripeProductName($plan),
|
||||||
'metadata' => ['plan_slug' => $plan->slug],
|
'metadata' => ['plan_slug' => $plan->slug],
|
||||||
]);
|
]);
|
||||||
$productId = $product->id;
|
$productId = $product->id;
|
||||||
|
|
@ -114,7 +127,7 @@ class SyncStripePlans extends Command
|
||||||
|
|
||||||
$price = $stripe->prices->create([
|
$price = $stripe->prices->create([
|
||||||
'product' => $product->id,
|
'product' => $product->id,
|
||||||
'currency' => 'eur',
|
'currency' => strtolower((string) config('billing.default_currency')),
|
||||||
'unit_amount' => $amount,
|
'unit_amount' => $amount,
|
||||||
'tax_behavior' => 'exclusive',
|
'tax_behavior' => 'exclusive',
|
||||||
'metadata' => ['purpose' => 'single_pm'],
|
'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}");
|
$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
|
private function createPrice(object $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string
|
||||||
{
|
{
|
||||||
$price = $stripe->prices->create([
|
$price = $stripe->prices->create([
|
||||||
|
|
|
||||||
55
app/Enums/ActivityType.php
Normal file
55
app/Enums/ActivityType.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Enums/AiImageDeclaration.php
Normal file
67
app/Enums/AiImageDeclaration.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Enums/AiTextDeclaration.php
Normal file
94
app/Enums/AiTextDeclaration.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Enums/BoostPlacement.php
Normal file
34
app/Enums/BoostPlacement.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Enums/ContactMessageStatus.php
Normal file
30
app/Enums/ContactMessageStatus.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Enums/ContactMessageTopic.php
Normal file
26
app/Enums/ContactMessageTopic.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/Enums/EmailNotificationType.php
Normal file
65
app/Enums/EmailNotificationType.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,21 @@ enum Portal: string
|
||||||
case Businessportal24 = 'businessportal24';
|
case Businessportal24 = 'businessportal24';
|
||||||
case Both = 'both';
|
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
|
public function label(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,13 @@ enum PressReleaseClassification: string
|
||||||
self::Red => 'Rot',
|
self::Red => 'Rot',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function badgeClass(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Green => 'ok',
|
||||||
|
self::Yellow => 'warn',
|
||||||
|
self::Red => 'err',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
app/Enums/PressReleaseEventType.php
Normal file
16
app/Enums/PressReleaseEventType.php
Normal 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';
|
||||||
|
}
|
||||||
24
app/Enums/PressReleaseShareChannel.php
Normal file
24
app/Enums/PressReleaseShareChannel.php
Normal 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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,4 +20,19 @@ enum PressReleaseStatus: string
|
||||||
self::Archived => 'Archiviert',
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ enum SinglePurchaseType: string
|
||||||
case ExtraPm = 'extra_pm';
|
case ExtraPm = 'extra_pm';
|
||||||
case Boost = 'boost';
|
case Boost = 'boost';
|
||||||
case ProofPdf = 'proof_pdf';
|
case ProofPdf = 'proof_pdf';
|
||||||
|
case Depublication = 'depublication';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
|
|
@ -16,6 +17,23 @@ enum SinglePurchaseType: string
|
||||||
self::ExtraPm => 'Extra-PM',
|
self::ExtraPm => 'Extra-PM',
|
||||||
self::Boost => 'Boost / Platzierung',
|
self::Boost => 'Boost / Platzierung',
|
||||||
self::ProofPdf => 'Veröffentlichungsnachweis (PDF)',
|
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@
|
||||||
|
|
||||||
namespace App\Exceptions;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use App\Enums\BoostPlacement;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wird geworfen, wenn eine PM nicht geboostet werden darf — nur
|
* 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
|
class BoostNotAllowedException extends RuntimeException
|
||||||
{
|
{
|
||||||
|
|
@ -14,4 +17,11 @@ class BoostNotAllowedException extends RuntimeException
|
||||||
{
|
{
|
||||||
return new self('Nur veröffentlichte, grün klassifizierte Pressemitteilungen sind boostbar.');
|
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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
$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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
62
app/Http/Controllers/Api/Legacy/LegacyCompanyController.php
Normal file
62
app/Http/Controllers/Api/Legacy/LegacyCompanyController.php
Normal 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}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Controllers/Api/Legacy/LegacyContactController.php
Normal file
67
app/Http/Controllers/Api/Legacy/LegacyContactController.php
Normal 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}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php
Normal file
200
app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php
Normal 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')), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Http/Controllers/Api/Legacy/RespondsLikeLegacyApi.php
Normal file
58
app/Http/Controllers/Api/Legacy/RespondsLikeLegacyApi.php
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,10 +15,7 @@ class CompanyController extends Controller
|
||||||
abort_unless($request->user()->tokenCan('companies:read'), 403);
|
abort_unless($request->user()->tokenCan('companies:read'), 403);
|
||||||
|
|
||||||
$companies = Company::withoutGlobalScopes()
|
$companies = Company::withoutGlobalScopes()
|
||||||
->where(function ($query) use ($request): void {
|
->accessibleBy($request->user())
|
||||||
$query->where('owner_user_id', $request->user()->id)
|
|
||||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
|
||||||
})
|
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->paginate(min((int) $request->query('per_page', 25), 100));
|
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||||
|
|
||||||
|
|
@ -36,10 +33,7 @@ class CompanyController extends Controller
|
||||||
|
|
||||||
$company = Company::withoutGlobalScopes()
|
$company = Company::withoutGlobalScopes()
|
||||||
->whereKey($company)
|
->whereKey($company)
|
||||||
->where(function ($query) use ($request): void {
|
->accessibleBy($request->user())
|
||||||
$query->where('owner_user_id', $request->user()->id)
|
|
||||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
|
||||||
})
|
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
abort_unless($company !== null, 403);
|
abort_unless($company !== null, 403);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
|
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
|
||||||
use App\Models\NewsletterSubscription;
|
use App\Models\NewsletterSubscription;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class NewsletterSubscriptionController extends Controller
|
class NewsletterSubscriptionController extends Controller
|
||||||
|
|
@ -14,12 +15,27 @@ class NewsletterSubscriptionController extends Controller
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
|
|
||||||
$subscription = NewsletterSubscription::withoutGlobalScopes()->updateOrCreate(
|
$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'],
|
'portal' => $validated['portal'],
|
||||||
'email' => mb_strtolower($validated['email']),
|
'email' => $email,
|
||||||
],
|
|
||||||
[
|
|
||||||
'user_id' => $request->user()->id,
|
'user_id' => $request->user()->id,
|
||||||
'salutation_key' => $validated['salutation_key'] ?? null,
|
'salutation_key' => $validated['salutation_key'] ?? null,
|
||||||
'first_name' => $validated['first_name'] ?? null,
|
'first_name' => $validated['first_name'] ?? null,
|
||||||
|
|
@ -29,8 +45,27 @@ class NewsletterSubscriptionController extends Controller
|
||||||
'confirmation_token' => Str::random(32),
|
'confirmation_token' => Str::random(32),
|
||||||
'subscribed_at' => now(),
|
'subscribed_at' => now(),
|
||||||
'unsubscribed_at' => null,
|
'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(),
|
||||||
|
'subscribed_at' => now(),
|
||||||
|
'unsubscribed_at' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! $subscription->is_confirmed) {
|
||||||
|
$attributes['confirmation_token'] = Str::random(32);
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription->update($attributes);
|
||||||
|
|
||||||
|
return $subscription;
|
||||||
|
});
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Newsletter subscription created.',
|
'message' => 'Newsletter subscription created.',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\AiTextDeclaration;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
|
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\Http\Resources\PressReleaseResource;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||||||
use App\Services\PressRelease\BlacklistViolationException;
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
use App\Services\PressRelease\BookingRequiredException;
|
use App\Services\PressRelease\BookingRequiredException;
|
||||||
|
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||||
use App\Services\PressRelease\PressReleaseService;
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
use App\Services\PressRelease\QuotaExceededException;
|
use App\Services\PressRelease\QuotaExceededException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
@ -18,6 +21,7 @@ use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class PressReleaseController extends Controller
|
class PressReleaseController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -27,7 +31,7 @@ class PressReleaseController extends Controller
|
||||||
|
|
||||||
$pressReleases = PressRelease::withoutGlobalScopes()
|
$pressReleases = PressRelease::withoutGlobalScopes()
|
||||||
->where('user_id', $request->user()->id)
|
->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))
|
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate(min((int) $request->query('per_page', 25), 100));
|
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||||
|
|
@ -37,28 +41,40 @@ class PressReleaseController extends Controller
|
||||||
|
|
||||||
public function store(StorePressReleaseRequest $request): JsonResponse
|
public function store(StorePressReleaseRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $this->sanitizeHtmlText($request->validated());
|
||||||
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
|
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
|
||||||
abort_unless($company !== null, 403);
|
abort_unless($company !== null, 403);
|
||||||
|
|
||||||
$pressRelease = PressRelease::withoutGlobalScopes()->create([
|
$pressRelease = new PressRelease([
|
||||||
...$validated,
|
...$validated,
|
||||||
'uuid' => (string) Str::uuid(),
|
'uuid' => (string) Str::uuid(),
|
||||||
'user_id' => $request->user()->id,
|
'user_id' => $request->user()->id,
|
||||||
'portal' => $company->portal->value,
|
'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
|
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
|
||||||
// `review` erfolgt ausschließlich über die explizite submit-Route,
|
// `review` erfolgt ausschließlich über die explizite submit-Route,
|
||||||
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
|
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
|
||||||
'status' => PressReleaseStatus::Draft->value,
|
'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(
|
return PressReleaseResource::make(
|
||||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
$pressRelease->load($this->apiResourceRelations())
|
||||||
)->response()->setStatusCode(201);
|
)->response()->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,7 +85,7 @@ class PressReleaseController extends Controller
|
||||||
abort_unless($pressRelease !== null, 403);
|
abort_unless($pressRelease !== null, 403);
|
||||||
|
|
||||||
return PressReleaseResource::make(
|
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);
|
], 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $this->sanitizeHtmlText($request->validated());
|
||||||
$company = isset($validated['company_id'])
|
$company = isset($validated['company_id'])
|
||||||
? $this->findOwnedCompany((int) $validated['company_id'], $request)
|
? $this->findOwnedCompany((int) $validated['company_id'], $request)
|
||||||
: $this->findOwnedCompany((int) $pressRelease->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('language', $validated)
|
||||||
|| array_key_exists('company_id', $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.
|
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen.
|
||||||
if ($pressRelease->wasChanged(['title', 'text'])) {
|
if ($pressRelease->wasChanged(['title', 'text'])) {
|
||||||
|
|
@ -117,7 +140,7 @@ class PressReleaseController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
return PressReleaseResource::make(
|
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
|
public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
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);
|
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||||
abort_unless($pressRelease !== null, 403);
|
abort_unless($pressRelease !== null, 403);
|
||||||
|
|
||||||
|
|
@ -141,8 +174,13 @@ class PressReleaseController extends Controller
|
||||||
], 409);
|
], 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$pressRelease->update([
|
||||||
|
'ai_text_declaration' => $validated['ai_text_declaration'],
|
||||||
|
'ai_declaration_confirmed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$service->submitForReview($pressRelease);
|
$service->submitForReview($pressRelease, 'api');
|
||||||
} catch (BookingRequiredException $exception) {
|
} catch (BookingRequiredException $exception) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
|
|
@ -151,6 +189,10 @@ class PressReleaseController extends Controller
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
], 422);
|
], 422);
|
||||||
|
} catch (AiDeclarationRequiredException $exception) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
], 422);
|
||||||
} catch (BlacklistViolationException $exception) {
|
} catch (BlacklistViolationException $exception) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
|
|
@ -158,7 +200,7 @@ class PressReleaseController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
return PressReleaseResource::make(
|
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()
|
return Company::withoutGlobalScopes()
|
||||||
->whereKey($companyId)
|
->whereKey($companyId)
|
||||||
->where(function ($query) use ($request): void {
|
->accessibleBy($request->user())
|
||||||
$query->where('owner_user_id', $request->user()->id)
|
|
||||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
|
||||||
})
|
|
||||||
->first();
|
->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
|
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||||||
{
|
{
|
||||||
return PressRelease::withoutGlobalScopes()
|
return PressRelease::withoutGlobalScopes()
|
||||||
->whereKey($pressReleaseId)
|
->whereKey($pressReleaseId)
|
||||||
->where('user_id', $request->user()->id)
|
->ownedByApiUser($request->user())
|
||||||
->first();
|
->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);
|
return str_starts_with((string) $request->route()?->getName(), 'legacy-service.');
|
||||||
$candidate = $slug;
|
|
||||||
$suffix = 2;
|
|
||||||
|
|
||||||
while (
|
|
||||||
PressRelease::withoutGlobalScopes()
|
|
||||||
->where('portal', $portal)
|
|
||||||
->where('language', $language)
|
|
||||||
->where('slug', $candidate)
|
|
||||||
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
|
|
||||||
->exists()
|
|
||||||
) {
|
|
||||||
$candidate = "{$slug}-{$suffix}";
|
|
||||||
$suffix++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $candidate;
|
/**
|
||||||
|
* 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 $validated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\AiImageDeclaration;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
|
use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
|
||||||
|
|
@ -60,9 +61,13 @@ class PressReleaseImageController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
$image = $pressRelease->images()->create([
|
$image = $pressRelease->images()->create([
|
||||||
'disk' => 'public',
|
'disk' => $stored['disk'],
|
||||||
'path' => $stored['path'],
|
'path' => $stored['path'],
|
||||||
'variants' => $stored['variants'],
|
'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,
|
'title' => $validated['title'] ?? null,
|
||||||
'description' => $validated['description'] ?? null,
|
'description' => $validated['description'] ?? null,
|
||||||
'copyright' => $validated['copyright'] ?? null,
|
'copyright' => $validated['copyright'] ?? null,
|
||||||
|
|
@ -106,7 +111,7 @@ class PressReleaseImageController extends Controller
|
||||||
{
|
{
|
||||||
return PressRelease::withoutGlobalScopes()
|
return PressRelease::withoutGlobalScopes()
|
||||||
->whereKey($pressReleaseId)
|
->whereKey($pressReleaseId)
|
||||||
->where('user_id', $request->user()->id)
|
->ownedByApiUser($request->user())
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
45
app/Http/Controllers/Auth/ConfirmEmailChangeController.php
Normal file
45
app/Http/Controllers/Auth/ConfirmEmailChangeController.php
Normal 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.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Listeners\RecordAuthActivity;
|
||||||
use App\Services\Auth\SocialAuthService;
|
use App\Services\Auth\SocialAuthService;
|
||||||
use App\Support\LoginRedirect;
|
use App\Support\LoginRedirect;
|
||||||
use Illuminate\Http\RedirectResponse;
|
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);
|
Auth::login($user, remember: true);
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Listeners\RecordAuthActivity;
|
||||||
use App\Models\MagicLink;
|
use App\Models\MagicLink;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
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.'));
|
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_at' => now(),
|
||||||
'last_login_ip' => $request->ip(),
|
'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();
|
$request->session()->regenerate();
|
||||||
|
|
||||||
// Rollensicherer Redirect ohne intended(): eine als Gast besuchte
|
// Rollensicherer Redirect ohne intended(): eine als Gast besuchte
|
||||||
// Admin-URL (z. B. /dashboard) darf einen Customer nach dem
|
// Admin-URL (z. B. /dashboard) darf einen Customer nach dem
|
||||||
// Magic-Link-Login nicht in den 403-Admin-Bereich schicken.
|
// Magic-Link-Login nicht in den 403-Admin-Bereich schicken.
|
||||||
$user = $magicLink->user;
|
|
||||||
|
|
||||||
$home = $user->canAccessAdmin()
|
$home = $user->canAccessAdmin()
|
||||||
? route('dashboard', absolute: false)
|
? route('dashboard', absolute: false)
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,20 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Enums\SinglePurchaseStatus;
|
use App\Enums\SinglePurchaseStatus;
|
||||||
use App\Enums\SinglePurchaseType;
|
use App\Enums\SinglePurchaseType;
|
||||||
|
use App\Models\BookingConfirmation;
|
||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
|
use App\Models\PressRelease;
|
||||||
use App\Models\SinglePurchase;
|
use App\Models\SinglePurchase;
|
||||||
use App\Services\Billing\CreditTopupService;
|
use App\Services\Billing\CreditTopupService;
|
||||||
use App\Services\Billing\StripeCheckoutService;
|
use App\Services\Billing\StripeCheckoutService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Laravel\Cashier\Checkout;
|
use Laravel\Cashier\Checkout;
|
||||||
|
use Laravel\Cashier\Exceptions\IncompletePayment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einstieg in die Stripe-Checkouts (Phase 9E): Tarif-Abo und Einzel-PM.
|
* Einstieg in die Stripe-Checkouts (Phase 9E): Tarif-Abo und Einzel-PM.
|
||||||
|
|
@ -33,8 +38,12 @@ class CheckoutController extends Controller
|
||||||
return $this->backToProfile();
|
return $this->backToProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
|
||||||
|
return $redirect;
|
||||||
|
}
|
||||||
|
|
||||||
if ($user->subscribed()) {
|
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'
|
$priceId = $interval === 'yearly'
|
||||||
|
|
@ -48,12 +57,84 @@ class CheckoutController extends Controller
|
||||||
return $this->checkout->forSubscription($user, $plan, $interval);
|
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
|
public function singlePm(Request $request): Checkout|RedirectResponse
|
||||||
{
|
{
|
||||||
if (! $request->user()->hasCompleteBillingAddress()) {
|
if (! $request->user()->hasCompleteBillingAddress()) {
|
||||||
return $this->backToProfile();
|
return $this->backToProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
|
||||||
|
return $redirect;
|
||||||
|
}
|
||||||
|
|
||||||
if (! config('billing.single_pm_stripe_price_id')) {
|
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.'));
|
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,
|
'type' => SinglePurchaseType::SinglePm->value,
|
||||||
'status' => SinglePurchaseStatus::Pending->value,
|
'status' => SinglePurchaseStatus::Pending->value,
|
||||||
'price_cents' => (int) config('billing.single_pm_price_cents'),
|
'price_cents' => (int) config('billing.single_pm_price_cents'),
|
||||||
'currency' => 'EUR',
|
'currency' => config('billing.default_currency'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->checkout->forSinglePurchase($request->user(), $purchase);
|
return $this->checkout->forSinglePurchase($request->user(), $purchase);
|
||||||
|
|
@ -80,6 +161,10 @@ class CheckoutController extends Controller
|
||||||
return $this->backToProfile();
|
return $this->backToProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
|
||||||
|
return $redirect;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $topups->findPack($pack)) {
|
if (! $topups->findPack($pack)) {
|
||||||
return $this->backToBookings(__('Dieses Credit-Paket ist nicht verfügbar.'));
|
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);
|
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,
|
* Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode,
|
||||||
* Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll.
|
* Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll.
|
||||||
|
|
@ -111,6 +255,23 @@ class CheckoutController extends Controller
|
||||||
->with('checkout-notice', $notice);
|
->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
|
* Buchungs-Voraussetzung (12.06.2026): ohne vollständige
|
||||||
* Rechnungsadresse kein Checkout — der Hinweis erscheint direkt auf
|
* Rechnungsadresse kein Checkout — der Hinweis erscheint direkt auf
|
||||||
|
|
|
||||||
83
app/Http/Controllers/EmailUnsubscribeController.php
Normal file
83
app/Http/Controllers/EmailUnsubscribeController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ namespace App\Http\Controllers;
|
||||||
use App\Models\LegacyInvoice;
|
use App\Models\LegacyInvoice;
|
||||||
use App\Services\Billing\LegacyInvoicePdfRenderer;
|
use App\Services\Billing\LegacyInvoicePdfRenderer;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
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);
|
return $renderer->inlineResponse($legacyInvoice);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@ use App\Enums\LegalRequestStatus;
|
||||||
use App\Enums\LegalRequestType;
|
use App\Enums\LegalRequestType;
|
||||||
use App\Enums\Portal;
|
use App\Enums\Portal;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
|
use App\Mail\LegalRequestReceived;
|
||||||
|
use App\Mail\LegalRequestSubmitted;
|
||||||
use App\Models\LegalRequest;
|
use App\Models\LegalRequest;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
@ -39,6 +42,13 @@ class LegalRequestController extends Controller
|
||||||
{
|
{
|
||||||
$release = $this->resolvePublishedRelease($slug);
|
$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([
|
$validated = $request->validate([
|
||||||
'type' => ['required', Rule::enum(LegalRequestType::class)],
|
'type' => ['required', Rule::enum(LegalRequestType::class)],
|
||||||
// E-Mail für DSGVO/Persönlichkeitsrecht nötig (Rückmeldung); bei
|
// E-Mail für DSGVO/Persönlichkeitsrecht nötig (Rückmeldung); bei
|
||||||
|
|
@ -51,19 +61,10 @@ class LegalRequestController extends Controller
|
||||||
'nullable', 'email', 'max:255',
|
'nullable', 'email', 'max:255',
|
||||||
],
|
],
|
||||||
'message' => ['required', 'string', 'min:10', 'max:5000'],
|
'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']);
|
$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);
|
$this->ensureNotRateLimited($release);
|
||||||
|
|
||||||
// Bremse: max. 1 offene Anfrage pro PM + Typ (nicht hart gesperrt – andere
|
// 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);
|
RateLimiter::hit($this->throttleKey($release), 3600);
|
||||||
|
|
||||||
LegalRequest::query()->create([
|
$legalRequest = LegalRequest::query()->create([
|
||||||
'press_release_id' => $release->getKey(),
|
'press_release_id' => $release->getKey(),
|
||||||
'portal' => $release->portal?->value,
|
'portal' => $release->portal?->value,
|
||||||
'type' => $type->value,
|
'type' => $type->value,
|
||||||
|
|
@ -90,7 +91,23 @@ class LegalRequestController extends Controller
|
||||||
'requester_ip' => $request->ip(),
|
'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
|
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.'));
|
->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
|
private function resolvePublishedRelease(string $slug): PressRelease
|
||||||
{
|
{
|
||||||
$portal = str_contains(request()->getHost(), 'presseecho')
|
$portal = str_contains(request()->getHost(), 'presseecho')
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,17 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Http\Middleware\SetEdition;
|
||||||
use App\Models\MagicLink;
|
use App\Models\MagicLink;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use Illuminate\Contracts\View\View;
|
use App\Support\DomainAssetContext;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class PressReleasePreviewController extends Controller
|
class PressReleasePreviewController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(string $token): View|Response
|
public function __invoke(string $token): Response
|
||||||
{
|
{
|
||||||
$magicLink = MagicLink::query()
|
$magicLink = MagicLink::query()
|
||||||
->where('token_hash', hash('sha256', $token))
|
->where('token_hash', hash('sha256', $token))
|
||||||
|
|
@ -28,7 +31,14 @@ class PressReleasePreviewController extends Controller
|
||||||
|
|
||||||
$pressRelease = $pressReleaseId
|
$pressRelease = $pressReleaseId
|
||||||
? PressRelease::withoutGlobalScopes()
|
? 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)
|
->find($pressReleaseId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
@ -36,10 +46,81 @@ class PressReleasePreviewController extends Controller
|
||||||
return $this->renderError(__('Die Pressemitteilung wurde nicht gefunden.'), 404);
|
return $this->renderError(__('Die Pressemitteilung wurde nicht gefunden.'), 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('press-release-preview', [
|
$domainKey = $this->resolveTargetDomainKey($pressRelease);
|
||||||
'pressRelease' => $pressRelease,
|
|
||||||
'expiresAt' => $magicLink->expires_at,
|
$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
|
private function renderError(string $message, int $status): Response
|
||||||
|
|
|
||||||
44
app/Http/Controllers/StripeInvoiceRedirectController.php
Normal file
44
app/Http/Controllers/StripeInvoiceRedirectController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Http/Controllers/Web/CategoryController.php
Normal file
18
app/Http/Controllers/Web/CategoryController.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
131
app/Http/Controllers/Web/ContactController.php
Normal file
131
app/Http/Controllers/Web/ContactController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Controllers/Web/HomeController.php
Normal file
29
app/Http/Controllers/Web/HomeController.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Http/Controllers/Web/LegacyMediaRedirectController.php
Normal file
55
app/Http/Controllers/Web/LegacyMediaRedirectController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Http/Controllers/Web/NewsroomDetailController.php
Normal file
18
app/Http/Controllers/Web/NewsroomDetailController.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Http/Controllers/Web/NewsroomsController.php
Normal file
16
app/Http/Controllers/Web/NewsroomsController.php
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Http/Controllers/Web/PreiseController.php
Normal file
18
app/Http/Controllers/Web/PreiseController.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Http/Controllers/Web/ReleaseDetailController.php
Normal file
26
app/Http/Controllers/Web/ReleaseDetailController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Controllers/Web/ReleaseShareTrackingController.php
Normal file
45
app/Http/Controllers/Web/ReleaseShareTrackingController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
160
app/Http/Controllers/Web/ReportFormController.php
Normal file
160
app/Http/Controllers/Web/ReportFormController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/Http/Controllers/Web/SitemapController.php
Normal file
127
app/Http/Controllers/Web/SitemapController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Http/Controllers/Web/SucheController.php
Normal file
16
app/Http/Controllers/Web/SucheController.php
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/Http/Controllers/Web/TerminationController.php
Normal file
119
app/Http/Controllers/Web/TerminationController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Http/Controllers/Web/VeroeffentlichenController.php
Normal file
16
app/Http/Controllers/Web/VeroeffentlichenController.php
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/Http/Controllers/Web/WithdrawalController.php
Normal file
97
app/Http/Controllers/Web/WithdrawalController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,11 @@ class BasicAuthMiddleware
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
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
|
// Skip Basic Auth für Livewire-Requests komplett
|
||||||
// Diese sind bereits durch Laravel Session/CSRF geschützt
|
// Diese sind bereits durch Laravel Session/CSRF geschützt
|
||||||
$path = $request->path();
|
$path = $request->path();
|
||||||
|
|
@ -38,11 +43,27 @@ class BasicAuthMiddleware
|
||||||
return $next($request);
|
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');
|
$user = config('auth.basic.user');
|
||||||
$pass = config('auth.basic.password');
|
$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']);
|
return response('Unauthorized.', 401, ['WWW-Authenticate' => 'Basic']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
34
app/Http/Middleware/BlockAdminDuringImpersonation.php
Normal file
34
app/Http/Middleware/BlockAdminDuringImpersonation.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Actions\Admin\UserImpersonation;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,30 +2,22 @@
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Actions\Admin\UserImpersonation;
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
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
|
class EnsureUserIsAdmin
|
||||||
{
|
{
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
if (Gate::denies('access-admin')) {
|
||||||
|
|
||||||
if ($user !== null && $user->is_active && ! $user->is_super_admin) {
|
|
||||||
$user->loadMissing('roles');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (app(UserImpersonation::class)->isActive()) {
|
|
||||||
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
|
||||||
return redirect()->route('me.dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
abort(403, 'Während der Benutzer-Impersonation ist der Admin-Bereich gesperrt.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user?->canAccessAdmin()) {
|
|
||||||
abort(403, 'Kein Zugriff auf den Admin-Bereich.');
|
abort(403, 'Kein Zugriff auf den Admin-Bereich.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,18 @@ namespace App\Http\Middleware;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriffsprüfung auf das Kundenportal („Mein Bereich") über das
|
||||||
|
* `access-customer`-Gate (Rollen admin/editor/customer, aktiv).
|
||||||
|
*/
|
||||||
class EnsureUserIsCustomer
|
class EnsureUserIsCustomer
|
||||||
{
|
{
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
if (! $request->user()?->canAccessCustomer()) {
|
if (Gate::denies('access-customer')) {
|
||||||
abort(403, 'Kein Zugriff auf das Kundenportal.');
|
abort(403, 'Kein Zugriff auf das Kundenportal.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class LogApiUsage
|
class LogApiUsage
|
||||||
{
|
{
|
||||||
|
private const StartedAtKey = 'api_usage_started_at';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*
|
*
|
||||||
|
|
@ -17,12 +19,22 @@ class LogApiUsage
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
$startedAt = microtime(true);
|
$request->attributes->set(self::StartedAtKey, microtime(true));
|
||||||
$response = $next($request);
|
|
||||||
|
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);
|
$this->writeLog($request, $response, $startedAt);
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function writeLog(Request $request, Response $response, float $startedAt): void
|
private function writeLog(Request $request, Response $response, float $startedAt): void
|
||||||
|
|
|
||||||
37
app/Http/Middleware/RedirectWwwToApex.php
Normal file
37
app/Http/Middleware/RedirectWwwToApex.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,17 @@ namespace App\Http\Middleware;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Sanctum\PersonalAccessToken;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
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
|
class RejectLegacyApiKeys
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,14 +24,65 @@ class RejectLegacyApiKeys
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
if ($request->has('api_key') || filled($request->header('X-Api-Key'))) {
|
$legacyKey = $this->legacyKey($request);
|
||||||
return response()->json([
|
|
||||||
'message' => 'Legacy API keys are no longer supported.',
|
if ($legacyKey === null) {
|
||||||
'migration_url' => url('/customer/tokens'),
|
// Kein Header-Key: ein in Query/Body übergebener Alt-Schlüssel wird
|
||||||
'docs_url' => url('/docs/api/v1'),
|
// NICHT für die Auth akzeptiert (Zugangsdaten dürfen nicht in URLs/
|
||||||
], 410);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
app/Http/Middleware/SecurityHeaders.php
Normal file
35
app/Http/Middleware/SecurityHeaders.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,13 +44,8 @@ class SetCurrentPortal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Domain-basierte Auflösung via ThemeServiceProvider
|
// Domain-basierte Auflösung via ThemeServiceProvider; Mapping zentral
|
||||||
$theme = config('app.theme', 'main');
|
// im Portal-Enum (Admin/Portal-Domain → null, kein automatischer Filter).
|
||||||
|
return Portal::fromTheme(config('app.theme', 'main'));
|
||||||
return match ($theme) {
|
|
||||||
'presseecho' => Portal::Presseecho,
|
|
||||||
'businessportal24' => Portal::Businessportal24,
|
|
||||||
default => null, // Admin/Portal-Domain → kein automatischer Filter
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
namespace App\Http\Requests\Api\V1;
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\AiImageDeclaration;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StorePressReleaseImageRequest extends FormRequest
|
class StorePressReleaseImageRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
|
@ -18,12 +20,23 @@ class StorePressReleaseImageRequest extends FormRequest
|
||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* 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>
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$isLegacyServiceCall = str_starts_with((string) $this->route()?->getName(), 'legacy-service.');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:8192'],
|
'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'],
|
'title' => ['nullable', 'string', 'max:120'],
|
||||||
'description' => ['nullable', 'string', 'max:500'],
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
'copyright' => ['nullable', 'string', 'max:255'],
|
'copyright' => ['nullable', 'string', 'max:255'],
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,12 @@ class PressReleaseImageResource extends JsonResource
|
||||||
$variants = is_array($this->variants) ? $this->variants : [];
|
$variants = is_array($this->variants) ? $this->variants : [];
|
||||||
$variantUrls = [];
|
$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) {
|
foreach ($variants as $key => $relativePath) {
|
||||||
if (is_string($relativePath) && filled($relativePath)) {
|
if (is_string($relativePath) && filled($relativePath) && filled($url = $this->variantUrl($key))) {
|
||||||
$variantUrls[$key] = asset('storage/'.ltrim($relativePath, '/'));
|
$variantUrls[$key] = $url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
31
app/Http/Responses/FailedTwoFactorChallengeResponse.php
Normal file
31
app/Http/Responses/FailedTwoFactorChallengeResponse.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ use App\Services\PressRelease\BlacklistViolationException;
|
||||||
use App\Services\PressRelease\Classification\ClassificationManager;
|
use App\Services\PressRelease\Classification\ClassificationManager;
|
||||||
use App\Services\PressRelease\Classification\ClassificationResult;
|
use App\Services\PressRelease\Classification\ClassificationResult;
|
||||||
use App\Services\PressRelease\PressReleaseService;
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
|
use App\Services\PressRelease\QuotaExceededException;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
@ -80,6 +81,13 @@ class ClassifyPressRelease implements ShouldQueue
|
||||||
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText());
|
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText());
|
||||||
} catch (BlacklistViolationException) {
|
} catch (BlacklistViolationException) {
|
||||||
// publish() hat die PM bereits abgelehnt und den Autor benachrichtigt.
|
// 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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ class ScorePressRelease implements ShouldQueue
|
||||||
'model' => $result->model,
|
'model' => $result->model,
|
||||||
'result' => (string) $result->score,
|
'result' => (string) $result->score,
|
||||||
'reason' => $tier->label(),
|
'reason' => $tier->label(),
|
||||||
'raw_response' => $result->rawResponse,
|
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,33 @@
|
||||||
|
|
||||||
namespace App\Listeners;
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Enums\ActivityType;
|
||||||
|
use App\Enums\EmailNotificationType;
|
||||||
use App\Enums\InvoiceStatus;
|
use App\Enums\InvoiceStatus;
|
||||||
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Enums\SinglePurchaseStatus;
|
use App\Enums\SinglePurchaseStatus;
|
||||||
|
use App\Enums\SinglePurchaseType;
|
||||||
|
use App\Mail\BookingPaymentConfirmed;
|
||||||
|
use App\Mail\PressReleaseDepublished;
|
||||||
use App\Models\CreditTopup;
|
use App\Models\CreditTopup;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
use App\Models\InvoiceBillingAddress;
|
use App\Models\InvoiceBillingAddress;
|
||||||
|
use App\Models\PressRelease;
|
||||||
use App\Models\SinglePurchase;
|
use App\Models\SinglePurchase;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Services\ActivityLog;
|
||||||
use App\Services\Billing\CreditTopupService;
|
use App\Services\Billing\CreditTopupService;
|
||||||
use App\Services\Billing\InvoiceNumberGenerator;
|
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\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Laravel\Cashier\Cashier;
|
use Laravel\Cashier\Cashier;
|
||||||
use Laravel\Cashier\Events\WebhookReceived;
|
use Laravel\Cashier\Events\WebhookReceived;
|
||||||
|
use Laravel\Cashier\Subscription;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verarbeitet Stripe-Webhooks über das Cashier-Event (Phase 9E).
|
* Verarbeitet Stripe-Webhooks über das Cashier-Event (Phase 9E).
|
||||||
|
|
@ -32,17 +47,151 @@ use Laravel\Cashier\Events\WebhookReceived;
|
||||||
*/
|
*/
|
||||||
class ProcessStripeWebhook
|
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
|
public function handle(WebhookReceived $event): void
|
||||||
{
|
{
|
||||||
match ($event->payload['type'] ?? null) {
|
match ($event->payload['type'] ?? null) {
|
||||||
'invoice.payment_succeeded' => $this->mirrorPaidInvoice($event->payload['data']['object'] ?? []),
|
'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,
|
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
|
* @param array<string, mixed> $stripeInvoice
|
||||||
*/
|
*/
|
||||||
|
|
@ -54,7 +203,6 @@ class ProcessStripeWebhook
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent: Stripe liefert Webhooks mindestens einmal.
|
|
||||||
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
|
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -71,10 +219,34 @@ class ProcessStripeWebhook
|
||||||
}
|
}
|
||||||
|
|
||||||
$subtotal = (int) ($stripeInvoice['subtotal'] ?? 0);
|
$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);
|
$total = (int) ($stripeInvoice['total'] ?? $subtotal + $tax);
|
||||||
|
|
||||||
$invoice = Invoice::query()->create([
|
// 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,
|
'user_id' => $user->id,
|
||||||
'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id,
|
'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id,
|
||||||
'number' => $this->numbers->nextStripeNumber(),
|
'number' => $this->numbers->nextStripeNumber(),
|
||||||
|
|
@ -82,17 +254,50 @@ class ProcessStripeWebhook
|
||||||
'amount_cents' => $subtotal,
|
'amount_cents' => $subtotal,
|
||||||
'tax_cents' => $tax,
|
'tax_cents' => $tax,
|
||||||
'total_cents' => $total,
|
'total_cents' => $total,
|
||||||
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? 'eur')),
|
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? config('billing.default_currency'))),
|
||||||
'is_netto' => $tax === 0,
|
'is_netto' => $tax === 0,
|
||||||
'invoice_date' => now()->toDateString(),
|
'invoice_date' => now()->toDateString(),
|
||||||
'paid_at' => now(),
|
'paid_at' => now(),
|
||||||
'stripe_invoice_id' => $stripeInvoiceId,
|
'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.', [
|
Log::info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [
|
||||||
'number' => $invoice->number,
|
'number' => $invoice->number,
|
||||||
'stripe_invoice_id' => $stripeInvoiceId,
|
'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
|
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->fulfillSinglePurchase($session);
|
||||||
$this->fulfillCreditTopup($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
|
* @param array<string, mixed> $session
|
||||||
*/
|
*/
|
||||||
|
|
@ -168,19 +403,102 @@ class ProcessStripeWebhook
|
||||||
return;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$markedPaid = false;
|
||||||
|
|
||||||
|
if ($purchase->status === SinglePurchaseStatus::Pending) {
|
||||||
$purchase->update([
|
$purchase->update([
|
||||||
'status' => SinglePurchaseStatus::Paid->value,
|
'status' => SinglePurchaseStatus::Paid->value,
|
||||||
'paid_at' => now(),
|
'paid_at' => now(),
|
||||||
'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id,
|
'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id,
|
||||||
'stripe_payment_intent_id' => $session['payment_intent'] ?? null,
|
'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]);
|
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::Consumed->value,
|
||||||
|
'consumed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $pressRelease;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
app/Listeners/RecordAuthActivity.php
Normal file
73
app/Listeners/RecordAuthActivity.php
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Livewire/Concerns/HandlesAdminPressReleaseActions.php
Normal file
19
app/Livewire/Concerns/HandlesAdminPressReleaseActions.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Livewire/Concerns/HasFullTextSearch.php
Normal file
20
app/Livewire/Concerns/HasFullTextSearch.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Livewire/Concerns/HasSortableTable.php
Normal file
23
app/Livewire/Concerns/HasSortableTable.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Livewire/Concerns/InteractsWithAiDeclaration.php
Normal file
52
app/Livewire/Concerns/InteractsWithAiDeclaration.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Livewire/Concerns/InteractsWithPressReleaseContacts.php
Normal file
47
app/Livewire/Concerns/InteractsWithPressReleaseContacts.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
155
app/Livewire/Concerns/InteractsWithPressReleaseScheduling.php
Normal file
155
app/Livewire/Concerns/InteractsWithPressReleaseScheduling.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/Livewire/Concerns/InteractsWithPressReleaseTags.php
Normal file
97
app/Livewire/Concerns/InteractsWithPressReleaseTags.php
Normal 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,
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Livewire/Concerns/ProvidesPressReleaseFormRules.php
Normal file
70
app/Livewire/Concerns/ProvidesPressReleaseFormRules.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Livewire/Concerns/ProvidesUserFormRules.php
Normal file
75
app/Livewire/Concerns/ProvidesUserFormRules.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Mail/BookingPaymentConfirmed.php
Normal file
51
app/Mail/BookingPaymentConfirmed.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Mail/Concerns/Unsubscribable.php
Normal file
42
app/Mail/Concerns/Unsubscribable.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Mail/ContactRequestSubmitted.php
Normal file
44
app/Mail/ContactRequestSubmitted.php
Normal 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
Loading…
Add table
Add a link
Reference in a new issue