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_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_URL=https://pressekonto.test
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_LOCALE=de
|
||||
APP_FALLBACK_LOCALE=de
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# HTTP Basic Auth Gate (Staging-Schutz). Nur erzwungen, wenn aktiviert
|
||||
# UND BASIC_AUTH_USER/BASIC_AUTH_PASSWORD gesetzt sind.
|
||||
BASIC_AUTH_ENABLED=true
|
||||
BASIC_AUTH_USER=
|
||||
BASIC_AUTH_PASSWORD=
|
||||
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
# Prod: auf `daily` stellen — der `single`-Channel wächst sonst unbegrenzt.
|
||||
# Der daily-Channel rotiert mit LOG_DAILY_DAYS (Default 14, siehe Runbook).
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Betriebs-Alerts (R5.3): fehlgeschlagene Scheduler-Tasks/Queue-Jobs werden als
|
||||
# `critical` geloggt und – wenn aktiviert – zusätzlich an MONITORING_ALERT_EMAIL
|
||||
# gemeldet (Fallback: LEGAL_CONTACT_EMAIL). In Prod echten Mailer voraussetzen.
|
||||
MONITORING_ALERTS_ENABLED=true
|
||||
MONITORING_ALERT_EMAIL=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-Domain-Setup (config/domains.php)
|
||||
# Hub: pressekonto (Portal-Backend + Hub-Landing) — Spokes: presseecho,
|
||||
# businessportal24. Prod: .test durch echte Domains ersetzen
|
||||
# (pressekonto.de, presseecho.de, businessportal24.com).
|
||||
# ---------------------------------------------------------------------------
|
||||
APP_PROTOCOL="https://"
|
||||
|
||||
APP_PORTAL_NAME="pressekonto.test"
|
||||
APP_PORTAL_URL="https://pressekonto.test"
|
||||
APP_PORTAL_PRIMARY="#526266"
|
||||
APP_PORTAL_SECONDARY="#82a0a7"
|
||||
# APP_PORTAL_ASSET_URL=
|
||||
|
||||
APP_PRESSEECHO_NAME=presseecho.test
|
||||
APP_PRESSEECHO_URL=https://presseecho.test
|
||||
APP_PRESSEECHO_PRIMARY="#345636"
|
||||
APP_PRESSEECHO_SECONDARY="#6b8f71"
|
||||
|
||||
APP_BUSINESSPORTAL_NAME=businessportal24.test
|
||||
APP_BUSINESSPORTAL_URL=https://businessportal24.test
|
||||
APP_BUSINESSPORTAL_PRIMARY="#cf3628"
|
||||
APP_BUSINESSPORTAL_SECONDARY="#f0834a"
|
||||
|
||||
# Sanctum: Hosts, die stateful (Session-Cookie) auf die API zugreifen dürfen.
|
||||
# Prod: echte Domains kommasepariert eintragen.
|
||||
SANCTUM_STATEFUL_DOMAINS=pressekonto.test,presseecho.test,businessportal24.test
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
|
|
@ -27,11 +69,27 @@ DB_CONNECTION=sqlite
|
|||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy-Datenbanken (Alt-Systeme, read-only für Migration/Abgleich).
|
||||
# Die businessportal-Verbindung ist ein Snapshot-Dump, nicht live.
|
||||
# ---------------------------------------------------------------------------
|
||||
DB_HOST_BUSINESSPORTAL=127.0.0.1
|
||||
DB_DATABASE_BUSINESSPORTAL=
|
||||
DB_USERNAME_BUSINESSPORTAL=
|
||||
DB_PASSWORD_BUSINESSPORTAL=
|
||||
|
||||
DB_HOST_PRESSEECHO=127.0.0.1
|
||||
DB_DATABASE_PRESSEECHO=
|
||||
DB_USERNAME_PRESSEECHO=
|
||||
DB_PASSWORD_PRESSEECHO=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
# Prod: true setzen (Cookies nur über HTTPS).
|
||||
# SESSION_SECURE_COOKIE=true
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
|
@ -47,6 +105,7 @@ REDIS_HOST=127.0.0.1
|
|||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Prod: echten Mailer konfigurieren — Default `log` versendet NICHTS.
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
|
|
@ -63,6 +122,64 @@ AWS_BUCKET=
|
|||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
# Lokaler Vite-Dev-Server (dual-port Setup, siehe VITE-SETUP.md).
|
||||
VITE_DEV_SERVER_URL=https://assets.pressekonto.test
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stripe / Billing (Laravel Cashier)
|
||||
# Prod: Live-Keys + Live-Preis-IDs eintragen [EXTERN: R1.2].
|
||||
# ---------------------------------------------------------------------------
|
||||
STRIPE_KEY=
|
||||
STRIPE_SECRET=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
# Stripe-Preis-ID des Einzelkaufs (eine PM, 19 €).
|
||||
STRIPE_PRICE_SINGLE_PM=
|
||||
CASHIER_CURRENCY=eur
|
||||
CASHIER_CURRENCY_LOCALE=de_DE
|
||||
|
||||
# Umsatzsteuer/Rechnungsangaben.
|
||||
# BILLING_VAT_RATE=0.19
|
||||
# BILLING_DEFAULT_CURRENCY=EUR
|
||||
# BILLING_MANUAL_DUE_DAYS=14
|
||||
BILLING_OWN_VAT_ID=
|
||||
|
||||
# Abo-/Bestandsschutz-Gate (WS-5). Default (auch ohne Env-Key) ist true:
|
||||
# Einreichen zur Prüfung erfordert ein aktives Abo, einen bezahlten Einzelkauf
|
||||
# oder Bestandsschutz (grandfathered) — siehe User::hasActiveBooking().
|
||||
# Nur für lokale Entwicklung bewusst auf false setzen (Gate offen für alle).
|
||||
BILLING_ENFORCE_BOOKING=true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KI: Klassifikation (Red-Flag-Gate) + Content-Score (config/scoring.php).
|
||||
# Ohne OPENAI_API_KEY laufen die Jobs nicht — Queue-Worker erforderlich.
|
||||
# ---------------------------------------------------------------------------
|
||||
OPENAI_API_KEY=
|
||||
# OPENAI_API_URL=
|
||||
# OPENAI_MODEL=
|
||||
# OPENAI_TIMEOUT=30
|
||||
|
||||
# CLASSIFICATION_PROVIDER=openai
|
||||
# CLASSIFICATION_MODEL=
|
||||
# CLASSIFICATION_TIMEOUT=15
|
||||
# CLASSIFICATION_GREEN_DELAY=0
|
||||
# CLASSIFICATION_YELLOW_MANUAL=true
|
||||
|
||||
# CONTENT_SCORE_PROVIDER=openai
|
||||
# CONTENT_SCORE_MODEL=
|
||||
# CONTENT_SCORE_TIMEOUT=30
|
||||
# CONTENT_SCORE_HOCHWERTIG=80
|
||||
# CONTENT_SCORE_GEPRUEFT=60
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Newsletter-Sync (config/newsletter.php). Provider `placeholder` bindet den
|
||||
# Null-Client; enabled=true mit anderem Provider wirft bewusst, bis eine
|
||||
# echte Anbindung implementiert ist.
|
||||
# ---------------------------------------------------------------------------
|
||||
NEWSLETTER_SYNC_ENABLED=false
|
||||
NEWSLETTER_SYNC_PROVIDER=placeholder
|
||||
NEWSLETTER_SYNC_ENDPOINT=
|
||||
NEWSLETTER_SYNC_API_KEY=
|
||||
# NEWSLETTER_SYNC_TIMEOUT=10
|
||||
|
||||
# Google-Login (Laravel Socialite). Die Redirect-URI muss in der Google Cloud
|
||||
# Console EXAKT der Callback-URL der Portal-Domain entsprechen (Login läuft auf
|
||||
|
|
@ -73,8 +190,29 @@ GOOGLE_CLIENT_ID=
|
|||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=https://pressekonto.com/auth/google/callback
|
||||
|
||||
# Abo-/Bestandsschutz-Gate (WS-5). Ist der Schalter aus, darf jeder einreichen
|
||||
# (Dev-Default). Für den Launch auf true setzen: Einreichen zur Prüfung erfordert
|
||||
# dann ein aktives Abo, einen bezahlten Einzelkauf oder Bestandsschutz
|
||||
# (grandfathered). Siehe User::hasActiveBooking().
|
||||
BILLING_ENFORCE_BOOKING=false
|
||||
# Empfänger des öffentlichen Kontaktformulars je Brand. Ohne Wert fällt der
|
||||
# Versand auf MAIL_FROM_ADDRESS zurück. [EXTERN: echte Adressen eintragen]
|
||||
CONTACT_EMAIL_PRESSEKONTO=
|
||||
CONTACT_EMAIL_PRESSEECHO=
|
||||
CONTACT_EMAIL_BUSINESSPORTAL=
|
||||
|
||||
# Depublikation (T12): Netto-Preis in Cent fuer die freiwillige Entfernung
|
||||
# einer veroeffentlichten PM aus der oeffentlichen Ansicht (Tombstone bleibt).
|
||||
# Default 3500 (35 EUR netto, Entscheidung O6 vom 06.07.2026); 0 deaktiviert.
|
||||
BILLING_DEPUBLICATION_PRICE_CENTS=3500
|
||||
|
||||
# Selbst gehostete Umami-Webanalyse (sys.adametz.media; IDs je Brand in
|
||||
# config/domains.php). Erst beim Go-Live der Webseiten auf true setzen —
|
||||
# die Datenschutzerklärung (Abschnitt 5) beschreibt den Einsatz bereits.
|
||||
UMAMI_ENABLED=false
|
||||
|
||||
# Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB,
|
||||
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.
|
||||
# Ohne Wert gilt der Default info@pressekonto.com (config/legal.php).
|
||||
LEGAL_CONTACT_EMAIL=
|
||||
|
||||
# Optionales separates DSA-Melde-Postfach je Brand (Impressum + /melden).
|
||||
# Ohne Wert fällt die Anzeige auf LEGAL_CONTACT_EMAIL zurück.
|
||||
REPORT_EMAIL_PRESSEKONTO=
|
||||
REPORT_EMAIL_PRESSEECHO=
|
||||
REPORT_EMAIL_BUSINESSPORTAL=
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -57,4 +57,11 @@ _static/
|
|||
_work/
|
||||
_storage/
|
||||
_businessportal24.com/
|
||||
dev/migration/
|
||||
dev/migration/
|
||||
# Produktivdaten: Legacy-SQL-Dumps bleiben lokal, nie ins Repo (Entscheidung 06.07.2026)
|
||||
dev/archive/migration-2026/sql/
|
||||
# Produktivdaten: API-Aktivitaets-Logs der Altportale (Kundendaten + Alt-API-Keys), analog SQL-Dumps
|
||||
dev/api/
|
||||
|
||||
# Rechtstext-Quellen des Users (untracked Arbeitsstand)
|
||||
docs/legal/
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Actions\Admin;
|
||||
|
||||
use App\Listeners\RecordAuthActivity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -20,7 +21,8 @@ class UserImpersonation
|
|||
return $this->canInitiate($admin)
|
||||
&& ! $admin->is($target)
|
||||
&& ! $this->isActive()
|
||||
&& $this->canBeImpersonated($target);
|
||||
&& $this->canBeImpersonated($target)
|
||||
&& $this->mayImpersonateRank($admin, $target);
|
||||
}
|
||||
|
||||
public function canBeImpersonated(User $target): bool
|
||||
|
|
@ -28,6 +30,16 @@ class UserImpersonation
|
|||
return $target->canAccessCustomer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rang-Schutz: Ein Super-Admin darf nur von einem Super-Admin im Panel
|
||||
* angemeldet werden. Verhindert, dass ein Admin (users:manage) das mächtigere
|
||||
* Super-Admin-Konto übernimmt und in dessen Namen z. B. API-Token erzeugt.
|
||||
*/
|
||||
public function mayImpersonateRank(User $admin, User $target): bool
|
||||
{
|
||||
return $admin->is_super_admin || ! $target->is_super_admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
|
|
@ -49,8 +61,13 @@ class UserImpersonation
|
|||
throw new AuthorizationException(__('Dieser Benutzer kann nicht im Panel angemeldet werden.'));
|
||||
}
|
||||
|
||||
if (! $this->mayImpersonateRank($admin, $target)) {
|
||||
throw new AuthorizationException(__('Super-Admins können nur von Super-Admins im Panel angemeldet werden.'));
|
||||
}
|
||||
|
||||
session([self::SessionKey => $admin->getKey()]);
|
||||
|
||||
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'impersonation');
|
||||
Auth::login($target);
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +87,7 @@ class UserImpersonation
|
|||
return null;
|
||||
}
|
||||
|
||||
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'impersonation');
|
||||
Auth::login($admin);
|
||||
|
||||
return $admin;
|
||||
|
|
|
|||
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.';
|
||||
|
||||
private const PORTAL_DB = [
|
||||
'presseecho' => 'presseecho',
|
||||
'businessportal24' => 'businessportal',
|
||||
/** Mapping: Portal → Legacy-Connection; der Schema-Name kommt aus deren Config (.env). */
|
||||
private const PORTAL_CONNECTIONS = [
|
||||
'presseecho' => 'mysql_presseecho',
|
||||
'businessportal24' => 'mysql_businessportal',
|
||||
];
|
||||
|
||||
/** Mapping: neue Tabelle → (legacy_portal, legacy_db, legacy_table, legacy_created_col) */
|
||||
|
|
@ -69,7 +70,7 @@ class FixLegacyTimestamps extends Command
|
|||
$isDryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$entities = $entityOpt === 'all' ? array_keys(self::ENTITY_CONFIG) : [$entityOpt];
|
||||
$portals = $portalOpt === 'all' ? array_keys(self::PORTAL_DB) : [$portalOpt];
|
||||
$portals = $portalOpt === 'all' ? array_keys(self::PORTAL_CONNECTIONS) : [$portalOpt];
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||
|
|
@ -87,9 +88,9 @@ class FixLegacyTimestamps extends Command
|
|||
}
|
||||
|
||||
foreach ($portals as $portal) {
|
||||
$legacyDb = self::PORTAL_DB[$portal] ?? null;
|
||||
$legacyDb = $this->legacySchema($portal);
|
||||
if (! $legacyDb) {
|
||||
$this->error("Unbekanntes Portal: {$portal}");
|
||||
$this->error("Unbekanntes Portal oder fehlende Legacy-DB-Config: {$portal}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -108,6 +109,19 @@ class FixLegacyTimestamps extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function legacySchema(string $portal): ?string
|
||||
{
|
||||
$connection = self::PORTAL_CONNECTIONS[$portal] ?? null;
|
||||
|
||||
if ($connection === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$database = config("database.connections.{$connection}.database");
|
||||
|
||||
return is_string($database) && $database !== '' ? $database : null;
|
||||
}
|
||||
|
||||
private function fixEntity(array $config, string $portal, string $legacyDb, bool $isDryRun): int
|
||||
{
|
||||
$newTable = $config['new_table'];
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ class GrandfatherLegacySubscriptions extends Command
|
|||
'type' => 'recurring',
|
||||
// Katalogpreise sind netto (Entscheidung 12.06.2026).
|
||||
'price_cents' => $candidate['legacy_conditions']['net_cents'],
|
||||
'currency' => 'EUR',
|
||||
'currency' => config('billing.default_currency'),
|
||||
'interval' => 'yearly',
|
||||
'is_hidden' => true,
|
||||
],
|
||||
|
|
|
|||
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\UserImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Legacy-Import-Master-Command (Phase 6 – Datenmigration).
|
||||
|
|
@ -22,6 +23,7 @@ use Illuminate\Console\Command;
|
|||
* php artisan legacy:import --source=all --step=categories
|
||||
* php artisan legacy:import --source=all --step=link-associations ← NEU
|
||||
* php artisan legacy:import --source=presseecho --force
|
||||
* php artisan legacy:import --source=presseecho --since=today --force ← Delta: nur heute Angelegtes/Geändertes
|
||||
*
|
||||
* Schritte (--step):
|
||||
* categories – Kategorien + Translations (einmalig, beide Portale identisch)
|
||||
|
|
@ -39,6 +41,7 @@ class ImportLegacyData extends Command
|
|||
{--step=all : Schritt (categories|users|companies|contacts|press-releases|link-associations|all)}
|
||||
{--dry-run : Nichts schreiben, nur zählen}
|
||||
{--force : Bereits importierte Datensätze erneut verarbeiten}
|
||||
{--since= : Nur Legacy-Zeilen mit created_at/updated_at ab diesem Zeitpunkt (z. B. "2026-07-20", "today"); für Updates mit --force kombinieren}
|
||||
{--chunk-size=500 : Batch-Größe (Debugging)}';
|
||||
|
||||
protected $description = 'Legacy-Daten aus Presseecho / Businessportal24 in die neue DB importieren.';
|
||||
|
|
@ -61,9 +64,29 @@ class ImportLegacyData extends Command
|
|||
$isDryRun = (bool) $this->option('dry-run');
|
||||
$isForce = (bool) $this->option('force');
|
||||
|
||||
$since = null;
|
||||
|
||||
if (filled($this->option('since'))) {
|
||||
try {
|
||||
$since = Carbon::parse((string) $this->option('since'), config('app.timezone'));
|
||||
} catch (\Throwable) {
|
||||
$this->error('Ungültiger --since-Wert: '.$this->option('since'));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$portals = $source === 'all' ? self::PORTALS : [$source];
|
||||
$steps = $step === 'all' ? self::STEPS : [$step];
|
||||
|
||||
if ($since) {
|
||||
$this->info('Delta-Modus: nur Legacy-Zeilen mit created_at/updated_at ab '.$since->format('d.m.Y H:i:s').'.');
|
||||
|
||||
if (! $isForce) {
|
||||
$this->warn('Hinweis: ohne --force werden im Zeitfenster GEÄNDERTE, bereits importierte Datensätze übersprungen — nur Neue kommen dazu.');
|
||||
}
|
||||
}
|
||||
|
||||
if (! $isDryRun && ! $this->option('force')) {
|
||||
$this->warn('WICHTIG: Dieser Command schreibt Daten in die Produktions-DB.');
|
||||
$this->line(' Portale: '.implode(', ', $portals));
|
||||
|
|
@ -86,7 +109,7 @@ class ImportLegacyData extends Command
|
|||
foreach ($steps as $currentStep) {
|
||||
// link-associations arbeitet auf der neuen DB → einmalig, portal-unabhängig
|
||||
if ($currentStep === 'link-associations') {
|
||||
$ctx = new ImportContext('all', $isDryRun, $isForce);
|
||||
$ctx = new ImportContext('all', $isDryRun, $isForce, $since);
|
||||
$this->info('▶ Schritt [link-associations] (beide Portale, neue DB)');
|
||||
$start = microtime(true);
|
||||
$result = app(UserAssociationLinker::class)->run($ctx);
|
||||
|
|
@ -100,7 +123,7 @@ class ImportLegacyData extends Command
|
|||
$stepPortals = ($currentStep === 'categories') ? [$portals[0]] : $portals;
|
||||
|
||||
foreach ($stepPortals as $portal) {
|
||||
$ctx = new ImportContext($portal, $isDryRun, $isForce);
|
||||
$ctx = new ImportContext($portal, $isDryRun, $isForce, $since);
|
||||
|
||||
$this->info("▶ Schritt [{$currentStep}] Portal [{$portal}]");
|
||||
$start = microtime(true);
|
||||
|
|
@ -120,6 +143,16 @@ class ImportLegacyData extends Command
|
|||
}
|
||||
}
|
||||
|
||||
// Selbstheilung: Ein User-Insert (updateOrCreate) und die anschließende
|
||||
// Rollen-Zuweisung sind nicht atomar – schlägt der Rollen-Sync nach dem
|
||||
// Insert fehl, bliebe ein aktives, rollenloses Konto zurück (hängt beim
|
||||
// Login auf der Startseite). Nach einem User-Import daher absichern.
|
||||
if (! $isDryRun && in_array('users', $steps, true)) {
|
||||
$this->newLine();
|
||||
$this->info('▶ Nachlauf: aktive rollenlose Nutzer absichern');
|
||||
$this->call('users:backfill-portal-role');
|
||||
}
|
||||
|
||||
$total = round(microtime(true) - $totalStart, 1);
|
||||
$this->newLine();
|
||||
$this->info("Import abgeschlossen in {$total}s.");
|
||||
|
|
|
|||
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\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Console\Command;
|
||||
|
|
@ -47,7 +48,7 @@ class PublishScheduledPressReleases extends Command
|
|||
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
|
||||
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
|
||||
// Fallback in der manuellen Queue.
|
||||
$candidates = PressRelease::withoutGlobalScopes()
|
||||
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
|
||||
->where('status', PressReleaseStatus::Review->value)
|
||||
->whereIn('classification', [
|
||||
PressReleaseClassification::Green->value,
|
||||
|
|
|
|||
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.
|
||||
*
|
||||
* Temporärer Stub bis zum echten Tarif-/Credit-Modul. Läuft per Scheduler
|
||||
* am Monatsanfang (siehe routes/console.php).
|
||||
* Fester Bestandteil des Tarif-Modells (geprüft R1.3, 03.07.2026): Die
|
||||
* Plan-Kontingente (§2/§3.3 Decision-Update) gelten pro Kalendermonat;
|
||||
* User::pressReleaseQuotaRemaining() rechnet gegen diesen Zähler. Läuft
|
||||
* per Scheduler am Monatsersten (siehe routes/console.php). Bewusste
|
||||
* Vereinfachung: Reset am Kalendermonat, nicht an der individuellen
|
||||
* Abo-Periode ausgerichtet.
|
||||
*/
|
||||
class ResetMonthlyPressReleaseQuota extends Command
|
||||
{
|
||||
|
|
|
|||
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) {
|
||||
if ($plan->stripe_product_id && $plan->stripe_price_id_monthly && $plan->stripe_price_id_yearly) {
|
||||
$this->line("{$plan->slug}: vollständig verknüpft, übersprungen.");
|
||||
// Preise sind unveränderlich, der Produktname nicht: Er wird
|
||||
// bei jedem Lauf nachgezogen, damit Stripe-Belege den
|
||||
// sprechenden Namen tragen (statt z. B. nur "Starter").
|
||||
if ($dryRun) {
|
||||
$this->line("[dry-run] {$plan->slug}: verknüpft — Produktname auf \"{$this->stripeProductName($plan)}\" aktualisieren.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$stripe->products->update($plan->stripe_product_id, [
|
||||
'name' => $this->stripeProductName($plan),
|
||||
]);
|
||||
|
||||
$this->line("{$plan->slug}: verknüpft — Produktname aktualisiert.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -56,7 +69,7 @@ class SyncStripePlans extends Command
|
|||
|
||||
if (! $productId) {
|
||||
$product = $stripe->products->create([
|
||||
'name' => $plan->name,
|
||||
'name' => $this->stripeProductName($plan),
|
||||
'metadata' => ['plan_slug' => $plan->slug],
|
||||
]);
|
||||
$productId = $product->id;
|
||||
|
|
@ -114,7 +127,7 @@ class SyncStripePlans extends Command
|
|||
|
||||
$price = $stripe->prices->create([
|
||||
'product' => $product->id,
|
||||
'currency' => 'eur',
|
||||
'currency' => strtolower((string) config('billing.default_currency')),
|
||||
'unit_amount' => $amount,
|
||||
'tax_behavior' => 'exclusive',
|
||||
'metadata' => ['purpose' => 'single_pm'],
|
||||
|
|
@ -124,6 +137,19 @@ class SyncStripePlans extends Command
|
|||
$this->warn("Bitte in die .env eintragen: STRIPE_PRICE_SINGLE_PM={$price->id}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprechender Produktname für Stripe-Belege: Auf Rechnung und
|
||||
* Checkout-Seite erscheint sonst nur der nackte Tarifname.
|
||||
*/
|
||||
private function stripeProductName(Plan $plan): string
|
||||
{
|
||||
return sprintf(
|
||||
'Tarif %s — %d Pressemitteilungen/Monat (pressekonto)',
|
||||
$plan->name,
|
||||
$plan->press_release_quota,
|
||||
);
|
||||
}
|
||||
|
||||
private function createPrice(object $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string
|
||||
{
|
||||
$price = $stripe->prices->create([
|
||||
|
|
|
|||
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 Both = 'both';
|
||||
|
||||
/**
|
||||
* Mappt ein Domain-Theme (aus config/domains.php, gesetzt vom
|
||||
* ThemeServiceProvider) auf das zugehörige Portal. Admin-/Hub-Themes
|
||||
* (portal, pressekonto, main, …) liefern null → kein Portal-Filter.
|
||||
* Single Source of Truth für die Theme→Portal-Auflösung.
|
||||
*/
|
||||
public static function fromTheme(?string $theme): ?self
|
||||
{
|
||||
return match ($theme) {
|
||||
'presseecho' => self::Presseecho,
|
||||
'businessportal24' => self::Businessportal24,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
|
|||
|
|
@ -24,4 +24,13 @@ enum PressReleaseClassification: string
|
|||
self::Red => 'Rot',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeClass(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Green => 'ok',
|
||||
self::Yellow => 'warn',
|
||||
self::Red => 'err',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Boost = 'boost';
|
||||
case ProofPdf = 'proof_pdf';
|
||||
case Depublication = 'depublication';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -16,6 +17,23 @@ enum SinglePurchaseType: string
|
|||
self::ExtraPm => 'Extra-PM',
|
||||
self::Boost => 'Boost / Platzierung',
|
||||
self::ProofPdf => 'Veröffentlichungsnachweis (PDF)',
|
||||
self::Depublication => 'Depublikation (Entfernung aus der öffentlichen Ansicht)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kompaktes Label für Listenzeilen, in denen der Kauftyp dem PM-Titel
|
||||
* vorangestellt wird (z. B. „Depublikation: <Titel>" im Verlauf der
|
||||
* Buchungs-Seite).
|
||||
*/
|
||||
public function shortLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SinglePm => 'Einzel-Pressemitteilung',
|
||||
self::ExtraPm => 'Extra-PM',
|
||||
self::Boost => 'Boost',
|
||||
self::ProofPdf => 'Veröffentlichungsnachweis',
|
||||
self::Depublication => 'Depublikation',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Enums\BoostPlacement;
|
||||
use Illuminate\Support\Carbon;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Wird geworfen, wenn eine PM nicht geboostet werden darf — nur
|
||||
* veröffentlichte, grün klassifizierte Meldungen sind boostbar (§2.2).
|
||||
* veröffentlichte, grün klassifizierte Meldungen sind boostbar (§2.2) und
|
||||
* je Ausgabe gibt es nur begrenzte Slots (Entscheidung 13.07.2026).
|
||||
*/
|
||||
class BoostNotAllowedException extends RuntimeException
|
||||
{
|
||||
|
|
@ -14,4 +17,11 @@ class BoostNotAllowedException extends RuntimeException
|
|||
{
|
||||
return new self('Nur veröffentlichte, grün klassifizierte Pressemitteilungen sind boostbar.');
|
||||
}
|
||||
|
||||
public static function soldOut(BoostPlacement $placement, ?Carbon $freeAt): self
|
||||
{
|
||||
return new self($freeAt
|
||||
? "{$placement->label()} derzeit ausgebucht — der nächste Platz wird am {$freeAt->format('d.m.Y')} frei."
|
||||
: "{$placement->label()} derzeit ausgebucht.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,13 +34,39 @@ class ThemeHelper
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the favicon path based on the current theme
|
||||
* Map the current theme to its icon folder under public/icons.
|
||||
*
|
||||
* Das Portal-/Admin-Theme läuft auf pressekonto.test und nutzt daher
|
||||
* den pressekonto-Iconsatz.
|
||||
*/
|
||||
public static function getFaviconPath(): string
|
||||
public static function getIconFolder(): string
|
||||
{
|
||||
$theme = config('app.theme', 'portal');
|
||||
|
||||
return "img/favicons/{$theme}-favicon.ico";
|
||||
$folderMap = [
|
||||
'portal' => 'pressekonto',
|
||||
'pressekonto' => 'pressekonto',
|
||||
'presseecho' => 'presseecho',
|
||||
'businessportal24' => 'businessportal24',
|
||||
];
|
||||
|
||||
return $folderMap[$theme] ?? 'pressekonto';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the favicon (SVG) path based on the current theme.
|
||||
*/
|
||||
public static function getFaviconPath(): string
|
||||
{
|
||||
return 'icons/'.self::getIconFolder().'/favicon.svg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the larger app/touch icon (SVG) path based on the current theme.
|
||||
*/
|
||||
public static function getAppIconPath(): string
|
||||
{
|
||||
return 'icons/'.self::getIconFolder().'/icon.svg';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
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);
|
||||
|
||||
$companies = Company::withoutGlobalScopes()
|
||||
->where(function ($query) use ($request): void {
|
||||
$query->where('owner_user_id', $request->user()->id)
|
||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||
})
|
||||
->accessibleBy($request->user())
|
||||
->orderBy('name')
|
||||
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||
|
||||
|
|
@ -36,10 +33,7 @@ class CompanyController extends Controller
|
|||
|
||||
$company = Company::withoutGlobalScopes()
|
||||
->whereKey($company)
|
||||
->where(function ($query) use ($request): void {
|
||||
$query->where('owner_user_id', $request->user()->id)
|
||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||
})
|
||||
->accessibleBy($request->user())
|
||||
->first();
|
||||
|
||||
abort_unless($company !== null, 403);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
|
||||
use App\Models\NewsletterSubscription;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NewsletterSubscriptionController extends Controller
|
||||
|
|
@ -14,23 +15,57 @@ class NewsletterSubscriptionController extends Controller
|
|||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$subscription = NewsletterSubscription::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'portal' => $validated['portal'],
|
||||
'email' => mb_strtolower($validated['email']),
|
||||
],
|
||||
[
|
||||
$email = mb_strtolower($validated['email']);
|
||||
|
||||
$subscription = DB::transaction(function () use ($request, $validated, $email): NewsletterSubscription {
|
||||
$subscription = NewsletterSubscription::withoutGlobalScopes()
|
||||
->where('portal', $validated['portal'])
|
||||
->where('email', $email)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
// Nur ein fremder, tatsächlich gesetzter Eigentümer blockiert (409).
|
||||
// Verwaiste Datensätze (user_id NULL nach Konto-Löschung/Legacy-Import)
|
||||
// dürfen neu gebunden werden — sonst wäre die portal+email-Kombination
|
||||
// dauerhaft nicht mehr abonnierbar.
|
||||
if ($subscription !== null && $subscription->user_id !== null && $subscription->user_id !== $request->user()->id) {
|
||||
abort(409, 'This email address is already subscribed.');
|
||||
}
|
||||
|
||||
if ($subscription === null) {
|
||||
return NewsletterSubscription::withoutGlobalScopes()->create([
|
||||
'portal' => $validated['portal'],
|
||||
'email' => $email,
|
||||
'user_id' => $request->user()->id,
|
||||
'salutation_key' => $validated['salutation_key'] ?? null,
|
||||
'first_name' => $validated['first_name'] ?? null,
|
||||
'last_name' => $validated['last_name'] ?? null,
|
||||
'ip_address' => $request->ip(),
|
||||
'is_confirmed' => false,
|
||||
'confirmation_token' => Str::random(32),
|
||||
'subscribed_at' => now(),
|
||||
'unsubscribed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'user_id' => $request->user()->id,
|
||||
'salutation_key' => $validated['salutation_key'] ?? null,
|
||||
'first_name' => $validated['first_name'] ?? null,
|
||||
'last_name' => $validated['last_name'] ?? null,
|
||||
'ip_address' => $request->ip(),
|
||||
'is_confirmed' => false,
|
||||
'confirmation_token' => Str::random(32),
|
||||
'subscribed_at' => now(),
|
||||
'unsubscribed_at' => null,
|
||||
],
|
||||
);
|
||||
];
|
||||
|
||||
if (! $subscription->is_confirmed) {
|
||||
$attributes['confirmation_token'] = Str::random(32);
|
||||
}
|
||||
|
||||
$subscription->update($attributes);
|
||||
|
||||
return $subscription;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Newsletter subscription created.',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\AiTextDeclaration;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
|
||||
|
|
@ -9,8 +10,10 @@ use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
|
|||
use App\Http\Resources\PressReleaseResource;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\BookingRequiredException;
|
||||
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -18,6 +21,7 @@ use Illuminate\Http\Request;
|
|||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PressReleaseController extends Controller
|
||||
{
|
||||
|
|
@ -27,7 +31,7 @@ class PressReleaseController extends Controller
|
|||
|
||||
$pressReleases = PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', $request->user()->id)
|
||||
->with(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
->with($this->apiResourceRelations())
|
||||
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
|
||||
->latest()
|
||||
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||
|
|
@ -37,28 +41,40 @@ class PressReleaseController extends Controller
|
|||
|
||||
public function store(StorePressReleaseRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$validated = $this->sanitizeHtmlText($request->validated());
|
||||
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
|
||||
abort_unless($company !== null, 403);
|
||||
|
||||
$pressRelease = PressRelease::withoutGlobalScopes()->create([
|
||||
$pressRelease = new PressRelease([
|
||||
...$validated,
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'user_id' => $request->user()->id,
|
||||
'portal' => $company->portal->value,
|
||||
'slug' => $this->uniqueSlug(
|
||||
Str::slug($validated['title']),
|
||||
$company->portal->value,
|
||||
$validated['language'],
|
||||
),
|
||||
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
|
||||
// `review` erfolgt ausschließlich über die explizite submit-Route,
|
||||
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
// Legacy-/service-Clients kennen die KI-Kennzeichnung (Art. 50
|
||||
// KI-VO) nicht — Entscheidung 10.07.: fehlende Angabe gilt als
|
||||
// „ohne generative KI erstellt"; das API-Kunden-Mailing (R6.4)
|
||||
// weist darauf hin, dass die weitere Nutzung diese Erklärung
|
||||
// darstellt. v1-Clients deklarieren explizit beim Submit.
|
||||
...($this->isLegacyServiceRequest($request) ? [
|
||||
'ai_text_declaration' => AiTextDeclaration::None->value,
|
||||
'ai_declaration_confirmed_at' => now(),
|
||||
] : []),
|
||||
]);
|
||||
$pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [
|
||||
'portal' => $company->portal->value,
|
||||
'language' => $validated['language'],
|
||||
]);
|
||||
$pressRelease->saveWithUniqueSlugRetry($validated['title'], [
|
||||
'portal' => $company->portal->value,
|
||||
'language' => $validated['language'],
|
||||
]);
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
$pressRelease->load($this->apiResourceRelations())
|
||||
)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +85,7 @@ class PressReleaseController extends Controller
|
|||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
$pressRelease->load($this->apiResourceRelations())
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +100,7 @@ class PressReleaseController extends Controller
|
|||
], 409);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$validated = $this->sanitizeHtmlText($request->validated());
|
||||
$company = isset($validated['company_id'])
|
||||
? $this->findOwnedCompany((int) $validated['company_id'], $request)
|
||||
: $this->findOwnedCompany((int) $pressRelease->company_id, $request);
|
||||
|
|
@ -104,10 +120,17 @@ class PressReleaseController extends Controller
|
|||
|| array_key_exists('language', $validated)
|
||||
|| array_key_exists('company_id', $validated)
|
||||
) {
|
||||
$pressRelease->slug = $this->uniqueSlug(Str::slug($title), $portal, $language, $pressRelease->id);
|
||||
// Selbst-Ausschluss übernimmt der Trait automatisch ($self->exists).
|
||||
$pressRelease->slug = $pressRelease->generateUniqueSlug($title, [
|
||||
'portal' => $portal,
|
||||
'language' => $language,
|
||||
]);
|
||||
}
|
||||
|
||||
$pressRelease->save();
|
||||
$pressRelease->saveWithUniqueSlugRetry($title, [
|
||||
'portal' => $portal,
|
||||
'language' => $language,
|
||||
]);
|
||||
|
||||
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen.
|
||||
if ($pressRelease->wasChanged(['title', 'text'])) {
|
||||
|
|
@ -117,7 +140,7 @@ class PressReleaseController extends Controller
|
|||
}
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
$pressRelease->load($this->apiResourceRelations())
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +155,16 @@ class PressReleaseController extends Controller
|
|||
public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse
|
||||
{
|
||||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||||
|
||||
// KI-Kennzeichnungspflicht (Art. 50 KI-VO) auch via API: der
|
||||
// Submit-Call trägt den deklarierten KI-Grad des Textes und gilt
|
||||
// dokumentiert als verbindliche Bestätigung der Deklaration sowie
|
||||
// Kenntnisnahme der dauerhaften Archivierung (AGB § 13) —
|
||||
// Entscheidung 10.07.2026.
|
||||
$validated = $request->validate([
|
||||
'ai_text_declaration' => ['required', Rule::enum(AiTextDeclaration::class)],
|
||||
]);
|
||||
|
||||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
|
|
@ -141,8 +174,13 @@ class PressReleaseController extends Controller
|
|||
], 409);
|
||||
}
|
||||
|
||||
$pressRelease->update([
|
||||
'ai_text_declaration' => $validated['ai_text_declaration'],
|
||||
'ai_declaration_confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$service->submitForReview($pressRelease);
|
||||
$service->submitForReview($pressRelease, 'api');
|
||||
} catch (BookingRequiredException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
|
|
@ -151,6 +189,10 @@ class PressReleaseController extends Controller
|
|||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
} catch (AiDeclarationRequiredException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
} catch (BlacklistViolationException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
|
|
@ -158,7 +200,7 @@ class PressReleaseController extends Controller
|
|||
}
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->fresh()->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||
$pressRelease->fresh()->load($this->apiResourceRelations())
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -183,39 +225,57 @@ class PressReleaseController extends Controller
|
|||
{
|
||||
return Company::withoutGlobalScopes()
|
||||
->whereKey($companyId)
|
||||
->where(function ($query) use ($request): void {
|
||||
$query->where('owner_user_id', $request->user()->id)
|
||||
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
|
||||
})
|
||||
->accessibleBy($request->user())
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationen für die API-Resource-Ausgabe. Zentral, damit Index, Show,
|
||||
* Create, Update und Reclassify denselben Satz laden.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function apiResourceRelations(): array
|
||||
{
|
||||
return ['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'];
|
||||
}
|
||||
|
||||
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->whereKey($pressReleaseId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->ownedByApiUser($request->user())
|
||||
->first();
|
||||
}
|
||||
|
||||
private function uniqueSlug(string $baseSlug, string $portal, string $language, ?int $excludeId = null): string
|
||||
/**
|
||||
* Kam der Call über die Legacy-Aliasse (/service/…, routes/legacy-api.php)?
|
||||
* Dort gelten Kompatibilitäts-Defaults für Felder, die die Altportale
|
||||
* nicht kannten (KI-Kennzeichnung).
|
||||
*/
|
||||
private function isLegacyServiceRequest(Request $request): bool
|
||||
{
|
||||
$slug = $baseSlug !== '' ? $baseSlug : Str::random(8);
|
||||
$candidate = $slug;
|
||||
$suffix = 2;
|
||||
return str_starts_with((string) $request->route()?->getName(), 'legacy-service.');
|
||||
}
|
||||
|
||||
while (
|
||||
PressRelease::withoutGlobalScopes()
|
||||
->where('portal', $portal)
|
||||
->where('language', $language)
|
||||
->where('slug', $candidate)
|
||||
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
|
||||
->exists()
|
||||
) {
|
||||
$candidate = "{$slug}-{$suffix}";
|
||||
$suffix++;
|
||||
/**
|
||||
* Säubert einen per API übergebenen HTML-Text vor der Persistenz — analog
|
||||
* zum Web-Formular, das immer über den Sanitizer speichert. Nur HTML wird
|
||||
* durch HTMLPurifier gejagt; Legacy-Plaintext bleibt unverändert (wird beim
|
||||
* Rendern ohnehin escaped), damit `<`/`&` in Klartext-Einreichungen nicht
|
||||
* zerstört werden.
|
||||
*
|
||||
* @param array<string, mixed> $validated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeHtmlText(array $validated): array
|
||||
{
|
||||
$sanitizer = app(PressReleaseHtmlSanitizer::class);
|
||||
|
||||
if (array_key_exists('text', $validated) && $sanitizer->isHtml($validated['text'])) {
|
||||
$validated['text'] = $sanitizer->clean($validated['text']);
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
return $validated;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
|
||||
|
|
@ -60,9 +61,13 @@ class PressReleaseImageController extends Controller
|
|||
}
|
||||
|
||||
$image = $pressRelease->images()->create([
|
||||
'disk' => 'public',
|
||||
'disk' => $stored['disk'],
|
||||
'path' => $stored['path'],
|
||||
'variants' => $stored['variants'],
|
||||
// v1 verlangt die KI-Kennzeichnung je Bild (FormRequest);
|
||||
// Legacy-/service-Calls ohne Feld gelten als Originalfoto
|
||||
// (Kompatibilitäts-Default, Entscheidung 10.07.2026).
|
||||
'ai_declaration' => $validated['ai_declaration'] ?? AiImageDeclaration::Original->value,
|
||||
'title' => $validated['title'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'copyright' => $validated['copyright'] ?? null,
|
||||
|
|
@ -106,7 +111,7 @@ class PressReleaseImageController extends Controller
|
|||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->whereKey($pressReleaseId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->ownedByApiUser($request->user())
|
||||
->first();
|
||||
}
|
||||
|
||||
|
|
|
|||
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;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Listeners\RecordAuthActivity;
|
||||
use App\Services\Auth\SocialAuthService;
|
||||
use App\Support\LoginRedirect;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -45,6 +46,7 @@ class GoogleController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'google');
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Listeners\RecordAuthActivity;
|
||||
use App\Models\MagicLink;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -45,18 +46,36 @@ class MagicLinkConsumeController extends Controller
|
|||
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
|
||||
}
|
||||
|
||||
$magicLink->user->update([
|
||||
$user = $magicLink->user;
|
||||
|
||||
// 2FA aktiv: nicht direkt einloggen, sondern – wie der Passwort-Login –
|
||||
// in Fortifys Challenge übergeben. Der Magic-Link (Besitz des Postfachs)
|
||||
// ersetzt nur das Passwort, nicht den zweiten Faktor. Der Link ist bereits
|
||||
// atomar verbraucht; bricht der Nutzer die Challenge ab, fordert er einen
|
||||
// neuen an.
|
||||
if ($user->hasEnabledTwoFactorAuthentication()) {
|
||||
$request->session()->put([
|
||||
'login.id' => $user->getKey(),
|
||||
'login.remember' => false,
|
||||
]);
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->route('two-factor.challenge');
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'last_login_at' => now(),
|
||||
'last_login_ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
Auth::guard('web')->login($magicLink->user);
|
||||
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'magic-link');
|
||||
Auth::guard('web')->login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
// Rollensicherer Redirect ohne intended(): eine als Gast besuchte
|
||||
// Admin-URL (z. B. /dashboard) darf einen Customer nach dem
|
||||
// Magic-Link-Login nicht in den 403-Admin-Bereich schicken.
|
||||
$user = $magicLink->user;
|
||||
|
||||
$home = $user->canAccessAdmin()
|
||||
? route('dashboard', absolute: false)
|
||||
|
|
|
|||
|
|
@ -2,15 +2,20 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
use App\Enums\SinglePurchaseType;
|
||||
use App\Models\BookingConfirmation;
|
||||
use App\Models\Plan;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Services\Billing\CreditTopupService;
|
||||
use App\Services\Billing\StripeCheckoutService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Cashier\Checkout;
|
||||
use Laravel\Cashier\Exceptions\IncompletePayment;
|
||||
|
||||
/**
|
||||
* Einstieg in die Stripe-Checkouts (Phase 9E): Tarif-Abo und Einzel-PM.
|
||||
|
|
@ -33,8 +38,12 @@ class CheckoutController extends Controller
|
|||
return $this->backToProfile();
|
||||
}
|
||||
|
||||
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
if ($user->subscribed()) {
|
||||
return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.'));
|
||||
return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Nutzen Sie den Tarifwechsel direkt an der gewünschten Tarif-Karte.'));
|
||||
}
|
||||
|
||||
$priceId = $interval === 'yearly'
|
||||
|
|
@ -48,12 +57,84 @@ class CheckoutController extends Controller
|
|||
return $this->checkout->forSubscription($user, $plan, $interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tarifwechsel eines aktiven Abos (POST aus dem Buchungs-Modal) — kein
|
||||
* neuer Stripe-Checkout: Stripe stellt das bestehende Abo sofort um und
|
||||
* verrechnet anteilig (swapAndInvoice). Eine vorgemerkte Kündigung wird
|
||||
* durch den Wechsel aufgehoben.
|
||||
*/
|
||||
public function swap(Request $request, string $planSlug, string $interval): RedirectResponse
|
||||
{
|
||||
$plan = Plan::query()->active()->where('slug', $planSlug)->firstOrFail();
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user->hasCompleteBillingAddress()) {
|
||||
return $this->backToProfile();
|
||||
}
|
||||
|
||||
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$subscription = $user->subscription();
|
||||
|
||||
if (! $subscription || ! $subscription->valid()) {
|
||||
return $this->backToBookings(__('Es besteht kein aktives Abo — bitte buchen Sie den gewünschten Tarif direkt.'));
|
||||
}
|
||||
|
||||
$priceId = $interval === 'yearly'
|
||||
? $plan->stripe_price_id_yearly
|
||||
: $plan->stripe_price_id_monthly;
|
||||
|
||||
if (! $priceId) {
|
||||
return $this->backToBookings(__('Dieser Tarif ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
|
||||
}
|
||||
|
||||
if ($subscription->stripe_price === $priceId) {
|
||||
return $this->backToBookings(__('Dieser Tarif ist bereits Ihr aktueller Tarif.'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Downgrade-Vorkehrung (QA-Befund 07.07.): Ein günstigerer Tarif
|
||||
// wird erst zum Ende der bezahlten Periode wirksam — es entsteht
|
||||
// nie eine Gutschrift oder ein negativer Rechnungsbetrag. Nur
|
||||
// Upgrades werden sofort umgestellt und anteilig nachberechnet.
|
||||
if ($this->checkout->isDowngrade($user, $plan, $interval)) {
|
||||
$this->checkout->scheduleDowngrade($user, $plan, $interval);
|
||||
|
||||
return redirect()->route('me.bookings.index', ['checkout' => 'wechsel-geplant']);
|
||||
}
|
||||
|
||||
$this->checkout->swapSubscription($user, $plan, $interval);
|
||||
} catch (IncompletePayment $exception) {
|
||||
return redirect()->route('cashier.payment', [
|
||||
$exception->payment->id,
|
||||
'redirect' => route('me.bookings.index', ['checkout' => 'wechsel']),
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('Tarifwechsel fehlgeschlagen.', [
|
||||
'user_id' => $user->id,
|
||||
'plan' => $plan->slug,
|
||||
'interval' => $interval,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->backToBookings(__('Der Tarifwechsel konnte nicht durchgeführt werden. Bitte versuchen Sie es erneut oder wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')]));
|
||||
}
|
||||
|
||||
return redirect()->route('me.bookings.index', ['checkout' => 'wechsel']);
|
||||
}
|
||||
|
||||
public function singlePm(Request $request): Checkout|RedirectResponse
|
||||
{
|
||||
if (! $request->user()->hasCompleteBillingAddress()) {
|
||||
return $this->backToProfile();
|
||||
}
|
||||
|
||||
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
if (! config('billing.single_pm_stripe_price_id')) {
|
||||
return $this->backToBookings(__('Die Einzel-Pressemitteilung ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
|
||||
}
|
||||
|
|
@ -63,7 +144,7 @@ class CheckoutController extends Controller
|
|||
'type' => SinglePurchaseType::SinglePm->value,
|
||||
'status' => SinglePurchaseStatus::Pending->value,
|
||||
'price_cents' => (int) config('billing.single_pm_price_cents'),
|
||||
'currency' => 'EUR',
|
||||
'currency' => config('billing.default_currency'),
|
||||
]);
|
||||
|
||||
return $this->checkout->forSinglePurchase($request->user(), $purchase);
|
||||
|
|
@ -80,6 +161,10 @@ class CheckoutController extends Controller
|
|||
return $this->backToProfile();
|
||||
}
|
||||
|
||||
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
if (! $topups->findPack($pack)) {
|
||||
return $this->backToBookings(__('Dieses Credit-Paket ist nicht verfügbar.'));
|
||||
}
|
||||
|
|
@ -89,6 +174,65 @@ class CheckoutController extends Controller
|
|||
return $this->checkout->forCreditTopup($request->user(), $topup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depublikation einer eigenen, veröffentlichten PM (T12, Meldeverfahren
|
||||
* Weg 3). Config-gated: ohne konfigurierten Preis ist der Weg
|
||||
* deaktiviert. Die Entfernung selbst übernimmt der Webhook nach
|
||||
* Zahlungseingang (Tombstone bleibt öffentlich sichtbar).
|
||||
*/
|
||||
public function depublication(Request $request, int $pressReleaseId): Checkout|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$priceCents = (int) config('billing.depublication_price_cents');
|
||||
|
||||
if ($priceCents <= 0) {
|
||||
return $this->backToBookings(__('Die Depublikation ist derzeit nicht online buchbar. Bitte wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')]));
|
||||
}
|
||||
|
||||
$pressRelease = PressRelease::query()
|
||||
->accessibleBy($user)
|
||||
->whereKey($pressReleaseId)
|
||||
->firstOrFail();
|
||||
|
||||
if ($pressRelease->status !== PressReleaseStatus::Published) {
|
||||
return $this->backToBookings(__('Nur veröffentlichte Pressemitteilungen können depubliziert werden.'));
|
||||
}
|
||||
|
||||
if (! $user->hasCompleteBillingAddress()) {
|
||||
return $this->backToProfile();
|
||||
}
|
||||
|
||||
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
// Doppelbuchungs-Bremse: nur eine BEZAHLTE Depublikation blockiert.
|
||||
// Ein offener Pending-Kauf (abgebrochener Stripe-Checkout) wird
|
||||
// wiederverwendet, damit der Abbruch den Weg nicht dauerhaft
|
||||
// versperrt (QA-Befund 07.07.).
|
||||
$existing = SinglePurchase::query()
|
||||
->where('press_release_id', $pressRelease->id)
|
||||
->where('type', SinglePurchaseType::Depublication->value)
|
||||
->whereIn('status', [SinglePurchaseStatus::Pending->value, SinglePurchaseStatus::Paid->value])
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing && $existing->status !== SinglePurchaseStatus::Pending) {
|
||||
return $this->backToBookings(__('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.'));
|
||||
}
|
||||
|
||||
$purchase = $existing ?? SinglePurchase::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'type' => SinglePurchaseType::Depublication->value,
|
||||
'status' => SinglePurchaseStatus::Pending->value,
|
||||
'price_cents' => $priceCents,
|
||||
'currency' => config('billing.default_currency'),
|
||||
'press_release_id' => $pressRelease->id,
|
||||
]);
|
||||
|
||||
return $this->checkout->forDepublication($user, $purchase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode,
|
||||
* Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll.
|
||||
|
|
@ -111,6 +255,23 @@ class CheckoutController extends Controller
|
|||
->with('checkout-notice', $notice);
|
||||
}
|
||||
|
||||
/**
|
||||
* B2B-Gate (T1neu, AGB v0.6 § 1 Abs. 4): Jeder Stripe-Checkout setzt eine
|
||||
* zeitnah protokollierte Unternehmer-Bestätigung voraus (Pflicht-Checkbox
|
||||
* im Buchungs-Modal). Direktaufrufe der Checkout-Routen ohne Bestätigung
|
||||
* werden auf die Buchungsseite zurückgeleitet.
|
||||
*/
|
||||
private function requireEntrepreneurConfirmation(Request $request): ?RedirectResponse
|
||||
{
|
||||
if (BookingConfirmation::query()->recentFor($request->user())->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->backToBookings(
|
||||
__('Bitte bestätigen Sie im Buchungsdialog, dass Sie als Unternehmer bestellen — das Angebot richtet sich ausschließlich an Unternehmen, Selbstständige und Agenturen.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buchungs-Voraussetzung (12.06.2026): ohne vollständige
|
||||
* Rechnungsadresse kein Checkout — der Hinweis erscheint direkt auf
|
||||
|
|
|
|||
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\Services\Billing\LegacyInvoicePdfRenderer;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
|
|
@ -23,9 +22,7 @@ class LegacyInvoicePdfController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('legacy_invoices', 'pdf_generated_at')) {
|
||||
$legacyInvoice->forceFill(['pdf_generated_at' => now()])->save();
|
||||
}
|
||||
$legacyInvoice->forceFill(['pdf_generated_at' => now()])->save();
|
||||
|
||||
return $renderer->inlineResponse($legacyInvoice);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ use App\Enums\LegalRequestStatus;
|
|||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Mail\LegalRequestReceived;
|
||||
use App\Mail\LegalRequestSubmitted;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
|
@ -39,6 +42,13 @@ class LegalRequestController extends Controller
|
|||
{
|
||||
$release = $this->resolvePublishedRelease($slug);
|
||||
|
||||
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
||||
// nichts speichern. Bewusst VOR der Validierung — eine size:0-Regel
|
||||
// würde mit ihrer Fehlermeldung einem Bot die Falle verraten.
|
||||
if (filled($request->input('website')) || filled($request->input('homepage'))) {
|
||||
return $this->done($release);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => ['required', Rule::enum(LegalRequestType::class)],
|
||||
// E-Mail für DSGVO/Persönlichkeitsrecht nötig (Rückmeldung); bei
|
||||
|
|
@ -51,19 +61,10 @@ class LegalRequestController extends Controller
|
|||
'nullable', 'email', 'max:255',
|
||||
],
|
||||
'message' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
// Honeypots: müssen leer bleiben (versteckte Felder, nur für Bots).
|
||||
'website' => ['nullable', 'size:0'],
|
||||
'homepage' => ['nullable', 'size:0'],
|
||||
]);
|
||||
|
||||
$type = LegalRequestType::from($validated['type']);
|
||||
|
||||
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
||||
// nichts speichern (keine Fehlermeldung, die einem Bot etwas verrät).
|
||||
if (filled($request->input('website')) || filled($request->input('homepage'))) {
|
||||
return $this->done($release);
|
||||
}
|
||||
|
||||
$this->ensureNotRateLimited($release);
|
||||
|
||||
// Bremse: max. 1 offene Anfrage pro PM + Typ (nicht hart gesperrt – andere
|
||||
|
|
@ -80,7 +81,7 @@ class LegalRequestController extends Controller
|
|||
|
||||
RateLimiter::hit($this->throttleKey($release), 3600);
|
||||
|
||||
LegalRequest::query()->create([
|
||||
$legalRequest = LegalRequest::query()->create([
|
||||
'press_release_id' => $release->getKey(),
|
||||
'portal' => $release->portal?->value,
|
||||
'type' => $type->value,
|
||||
|
|
@ -90,7 +91,23 @@ class LegalRequestController extends Controller
|
|||
'requester_ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
return $this->done($release);
|
||||
// T10/DSA Art. 16 Abs. 4: unverzügliche Eingangsbestätigung mit
|
||||
// Vorgangsnummer (sofern eine E-Mail-Adresse angegeben wurde) +
|
||||
// interner Hinweis ans Melde-Postfach.
|
||||
if ($legalRequest->requester_email) {
|
||||
Mail::to($legalRequest->requester_email)->queue(
|
||||
(new LegalRequestReceived($legalRequest))->afterCommit()
|
||||
);
|
||||
}
|
||||
Mail::to($this->operatorRecipient())->queue(
|
||||
(new LegalRequestSubmitted($legalRequest))->afterCommit()
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('release.detail', ['slug' => $release->slug])
|
||||
->with('legal-status', __('Vielen Dank. Ihre Anfrage ist unter der Vorgangsnummer :reference eingegangen und wird von unserem Team geprüft.', [
|
||||
'reference' => $legalRequest->reference(),
|
||||
]));
|
||||
}
|
||||
|
||||
private function done(PressRelease $release): RedirectResponse
|
||||
|
|
@ -100,6 +117,15 @@ class LegalRequestController extends Controller
|
|||
->with('legal-status', __('Vielen Dank. Ihre Anfrage ist eingegangen und wird von unserem Team geprüft.'));
|
||||
}
|
||||
|
||||
private function operatorRecipient(): string
|
||||
{
|
||||
$brand = str_contains(request()->getHost(), 'presseecho')
|
||||
? config('domains.domains.presseecho.brand', [])
|
||||
: config('domains.domains.businessportal24.brand', []);
|
||||
|
||||
return $brand['report_email'] ?? (string) config('legal.contact_email');
|
||||
}
|
||||
|
||||
private function resolvePublishedRelease(string $slug): PressRelease
|
||||
{
|
||||
$portal = str_contains(request()->getHost(), 'presseecho')
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Http\Middleware\SetEdition;
|
||||
use App\Models\MagicLink;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use App\Support\DomainAssetContext;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PressReleasePreviewController extends Controller
|
||||
{
|
||||
public function __invoke(string $token): View|Response
|
||||
public function __invoke(string $token): Response
|
||||
{
|
||||
$magicLink = MagicLink::query()
|
||||
->where('token_hash', hash('sha256', $token))
|
||||
|
|
@ -28,7 +31,14 @@ class PressReleasePreviewController extends Controller
|
|||
|
||||
$pressRelease = $pressReleaseId
|
||||
? PressRelease::withoutGlobalScopes()
|
||||
->with(['company:id,name,slug', 'category.translations', 'images', 'user:id,name'])
|
||||
->with([
|
||||
'company',
|
||||
'user:id,agency_name',
|
||||
'category.translations',
|
||||
'category.parent.translations',
|
||||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order'),
|
||||
'contacts',
|
||||
])
|
||||
->find($pressReleaseId)
|
||||
: null;
|
||||
|
||||
|
|
@ -36,10 +46,81 @@ class PressReleasePreviewController extends Controller
|
|||
return $this->renderError(__('Die Pressemitteilung wurde nicht gefunden.'), 404);
|
||||
}
|
||||
|
||||
return view('press-release-preview', [
|
||||
'pressRelease' => $pressRelease,
|
||||
'expiresAt' => $magicLink->expires_at,
|
||||
]);
|
||||
$domainKey = $this->resolveTargetDomainKey($pressRelease);
|
||||
|
||||
$this->applyTargetPortalPresentation($pressRelease, $domainKey);
|
||||
|
||||
return response()
|
||||
->view('web.release-detail', [
|
||||
'release' => $pressRelease,
|
||||
'companyReleases' => collect(),
|
||||
'relatedReleases' => collect(),
|
||||
'isPreview' => true,
|
||||
'previewExpiresAt' => $magicLink->expires_at,
|
||||
'previewPortalOptions' => $this->previewPortalOptions($pressRelease, $domainKey),
|
||||
])
|
||||
->header('X-Robots-Tag', 'noindex, nofollow');
|
||||
}
|
||||
|
||||
/**
|
||||
* Zielportal der Vorschau. Bei PMs für beide Portale wählt der optionale
|
||||
* Query-Parameter `portal` die Ansicht (Default: presseecho); bei
|
||||
* Einzel-Portal-PMs ist das Portal fix.
|
||||
*/
|
||||
private function resolveTargetDomainKey(PressRelease $pressRelease): string
|
||||
{
|
||||
if ($pressRelease->portal === Portal::Both) {
|
||||
$requested = request()->query('portal');
|
||||
|
||||
return in_array($requested, [Portal::Presseecho->value, Portal::Businessportal24->value], true)
|
||||
? $requested
|
||||
: Portal::Presseecho->value;
|
||||
}
|
||||
|
||||
return $pressRelease->portal === Portal::Businessportal24
|
||||
? Portal::Businessportal24->value
|
||||
: Portal::Presseecho->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Umschalt-Optionen für den Vorschau-Banner — nur bei PMs, die auf beiden
|
||||
* Portalen erscheinen.
|
||||
*
|
||||
* @return list<array{label: string, url: string, active: bool}>|null
|
||||
*/
|
||||
private function previewPortalOptions(PressRelease $pressRelease, string $activeDomainKey): ?array
|
||||
{
|
||||
if ($pressRelease->portal !== Portal::Both) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collect([Portal::Presseecho->value, Portal::Businessportal24->value])
|
||||
->map(fn (string $domainKey): array => [
|
||||
'label' => $domainKey,
|
||||
'url' => request()->url().'?portal='.$domainKey,
|
||||
'active' => $domainKey === $activeDomainKey,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert die Vorschau im Look des Zielportals: Theme-/Asset-Konfiguration
|
||||
* der Web-Domain anwenden, Links auf das Zielportal zeigen lassen und die
|
||||
* Ausgabe-Sprache der PM übernehmen — damit die Vorschau 1:1 der späteren
|
||||
* öffentlichen Detailseite entspricht.
|
||||
*/
|
||||
private function applyTargetPortalPresentation(PressRelease $pressRelease, string $domainKey): void
|
||||
{
|
||||
$domainConfig = DomainAssetContext::applyWebDomainConfig($domainKey);
|
||||
|
||||
URL::forceRootUrl($domainConfig['url'] ?? config('app.url'));
|
||||
|
||||
$language = in_array($pressRelease->language, SetEdition::EDITIONS, true)
|
||||
? $pressRelease->language
|
||||
: SetEdition::DEFAULT_EDITION;
|
||||
|
||||
app()->setLocale($language);
|
||||
URL::defaults(['edition' => $language]);
|
||||
}
|
||||
|
||||
private function renderError(string $message, int $status): Response
|
||||
|
|
|
|||
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
|
||||
{
|
||||
// Gate global deaktiviert (z. B. Produktion/Tests)
|
||||
if (! config('auth.basic.enabled', true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für Livewire-Requests komplett
|
||||
// Diese sind bereits durch Laravel Session/CSRF geschützt
|
||||
$path = $request->path();
|
||||
|
|
@ -38,11 +43,27 @@ class BasicAuthMiddleware
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
// Credentials from .env file
|
||||
// Skip Basic Auth für den Stripe-Webhook — Stripe sendet keine
|
||||
// Zugangsdaten mit; der Endpoint ist durch die Signaturprüfung
|
||||
// (STRIPE_WEBHOOK_SECRET) geschützt.
|
||||
if ($request->is('stripe/webhook')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Zugangsdaten aus der Config (gespeist aus .env). Ist nichts
|
||||
// konfiguriert, wird der Gate nicht erzwungen.
|
||||
$user = config('auth.basic.user');
|
||||
$pass = config('auth.basic.password');
|
||||
|
||||
if ($request->getUser() != $user || $request->getPassword() != $pass) {
|
||||
if (($user === null || $user === '') && ($pass === null || $pass === '')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Timing-sicherer Vergleich beider Felder (kein Short-Circuit).
|
||||
$userValid = hash_equals((string) $user, (string) $request->getUser());
|
||||
$passValid = hash_equals((string) $pass, (string) $request->getPassword());
|
||||
|
||||
if (! $userValid || ! $passValid) {
|
||||
return response('Unauthorized.', 401, ['WWW-Authenticate' => 'Basic']);
|
||||
}
|
||||
|
||||
|
|
|
|||
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;
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Reine Zugriffsprüfung auf den Admin-Bereich über das `access-admin`-Gate. Die
|
||||
* Impersonation-Sperre liegt in {@see BlockAdminDuringImpersonation} und läuft
|
||||
* davor. Bleibt eine konkrete Klasse (statt `can:access-admin`), weil sie als
|
||||
* persistente Livewire-Middleware registriert ist (AppServiceProvider).
|
||||
*/
|
||||
class EnsureUserIsAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user !== null && $user->is_active && ! $user->is_super_admin) {
|
||||
$user->loadMissing('roles');
|
||||
}
|
||||
|
||||
if (app(UserImpersonation::class)->isActive()) {
|
||||
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
||||
return redirect()->route('me.dashboard');
|
||||
}
|
||||
|
||||
abort(403, 'Während der Benutzer-Impersonation ist der Admin-Bereich gesperrt.');
|
||||
}
|
||||
|
||||
if (! $user?->canAccessAdmin()) {
|
||||
if (Gate::denies('access-admin')) {
|
||||
abort(403, 'Kein Zugriff auf den Admin-Bereich.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@ namespace App\Http\Middleware;
|
|||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Zugriffsprüfung auf das Kundenportal („Mein Bereich") über das
|
||||
* `access-customer`-Gate (Rollen admin/editor/customer, aktiv).
|
||||
*/
|
||||
class EnsureUserIsCustomer
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->canAccessCustomer()) {
|
||||
if (Gate::denies('access-customer')) {
|
||||
abort(403, 'Kein Zugriff auf das Kundenportal.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
|
||||
class LogApiUsage
|
||||
{
|
||||
private const StartedAtKey = 'api_usage_started_at';
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
|
|
@ -17,12 +19,22 @@ class LogApiUsage
|
|||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$response = $next($request);
|
||||
$request->attributes->set(self::StartedAtKey, microtime(true));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der INSERT läuft erst nach gesendeter Antwort (terminable Middleware) und
|
||||
* verlängert damit nicht mehr die wahrgenommene Request-Dauer. Der Startwert
|
||||
* wird über die Request-Attribute übergeben, da `terminate()` auf einer
|
||||
* frischen Middleware-Instanz aufgerufen wird.
|
||||
*/
|
||||
public function terminate(Request $request, Response $response): void
|
||||
{
|
||||
$startedAt = (float) $request->attributes->get(self::StartedAtKey, microtime(true));
|
||||
|
||||
$this->writeLog($request, $response, $startedAt);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function writeLog(Request $request, Response $response, float $startedAt): void
|
||||
|
|
|
|||
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 Illuminate\Http\Request;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Legacy-API-Kompatibilität (Übernahme bestehender Integrationen,
|
||||
* 06.07.2026): Die Altportale authentifizierten per `X-ApiKey`-Header.
|
||||
* Importierte Legacy-Keys (legacy:import-api-keys)
|
||||
* sind gültige Sanctum-Tokens — hier wird die alte Übergabeform in einen
|
||||
* Bearer-Header übersetzt, damit `auth:sanctum` sie versteht. Unbekannte
|
||||
* Keys erhalten weiterhin 410 mit Migrationshinweis.
|
||||
*/
|
||||
class RejectLegacyApiKeys
|
||||
{
|
||||
/**
|
||||
|
|
@ -15,14 +24,65 @@ class RejectLegacyApiKeys
|
|||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->has('api_key') || filled($request->header('X-Api-Key'))) {
|
||||
return response()->json([
|
||||
'message' => 'Legacy API keys are no longer supported.',
|
||||
'migration_url' => url('/customer/tokens'),
|
||||
'docs_url' => url('/docs/api/v1'),
|
||||
], 410);
|
||||
$legacyKey = $this->legacyKey($request);
|
||||
|
||||
if ($legacyKey === null) {
|
||||
// Kein Header-Key: ein in Query/Body übergebener Alt-Schlüssel wird
|
||||
// NICHT für die Auth akzeptiert (Zugangsdaten dürfen nicht in URLs/
|
||||
// Logs landen), erhält aber weiterhin den Migrationshinweis statt
|
||||
// eines nichtssagenden 401 — sofern kein regulärer Bearer-Token da ist.
|
||||
if (blank($request->bearerToken()) && $this->hasParameterKey($request)) {
|
||||
return $this->migrationHintResponse();
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
// Ein explizit gesetzter Bearer-Token hat Vorrang.
|
||||
if (filled($request->bearerToken())) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (PersonalAccessToken::findToken($legacyKey) !== null) {
|
||||
$request->headers->set('Authorization', 'Bearer '.$legacyKey);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return $this->migrationHintResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy-Schlüssel ausschließlich aus Headern lesen. Query- und
|
||||
* Body-Parameter werden bewusst nicht akzeptiert, damit Zugangsdaten
|
||||
* nicht in URLs, Proxy-Logs oder Referrer-Headern landen.
|
||||
*/
|
||||
private function legacyKey(Request $request): ?string
|
||||
{
|
||||
$key = trim((string) (
|
||||
$request->header('X-ApiKey')
|
||||
?: $request->header('X-Api-Key')
|
||||
));
|
||||
|
||||
return $key === '' ? null : $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Alt-Schlüssel per Query- oder Body-Parameter übergeben
|
||||
* wurde. Wird nur genutzt, um den Migrationshinweis auszulösen — der Wert
|
||||
* wird nie zur Authentifizierung verwendet.
|
||||
*/
|
||||
private function hasParameterKey(Request $request): bool
|
||||
{
|
||||
return filled($request->query('api_key')) || filled($request->input('api_key'));
|
||||
}
|
||||
|
||||
private function migrationHintResponse(): Response
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'This API key is not valid (anymore). Please generate a token in your customer account and send it as a Bearer token or X-ApiKey header.',
|
||||
'migration_url' => url('/customer/tokens'),
|
||||
'docs_url' => url('/docs/api/v1'),
|
||||
], 410);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
$theme = config('app.theme', 'main');
|
||||
|
||||
return match ($theme) {
|
||||
'presseecho' => Portal::Presseecho,
|
||||
'businessportal24' => Portal::Businessportal24,
|
||||
default => null, // Admin/Portal-Domain → kein automatischer Filter
|
||||
};
|
||||
// Domain-basierte Auflösung via ThemeServiceProvider; Mapping zentral
|
||||
// im Portal-Enum (Admin/Portal-Domain → null, kein automatischer Filter).
|
||||
return Portal::fromTheme(config('app.theme', 'main'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StorePressReleaseImageRequest extends FormRequest
|
||||
{
|
||||
|
|
@ -18,12 +20,23 @@ class StorePressReleaseImageRequest extends FormRequest
|
|||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* KI-Kennzeichnung je Bild (Art. 50 KI-VO) ist im v1-Vertrag Pflicht;
|
||||
* Legacy-/service-Clients kennen das Feld nicht — dort greift der
|
||||
* Kompatibilitäts-Default „original" (Entscheidung 10.07.2026).
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$isLegacyServiceCall = str_starts_with((string) $this->route()?->getName(), 'legacy-service.');
|
||||
|
||||
return [
|
||||
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:8192'],
|
||||
'ai_declaration' => [
|
||||
Rule::requiredIf(! $isLegacyServiceCall),
|
||||
'nullable',
|
||||
Rule::enum(AiImageDeclaration::class),
|
||||
],
|
||||
'title' => ['nullable', 'string', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'copyright' => ['nullable', 'string', 'max:255'],
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@ class PressReleaseImageResource extends JsonResource
|
|||
$variants = is_array($this->variants) ? $this->variants : [];
|
||||
$variantUrls = [];
|
||||
|
||||
// Variant-URLs über das Model auflösen, damit sie – wie die Original-URL –
|
||||
// je nach Disk entweder direkt (public) oder über die geschützte
|
||||
// Media-Route (local) zeigen. Sonst 404 für private Medien.
|
||||
foreach ($variants as $key => $relativePath) {
|
||||
if (is_string($relativePath) && filled($relativePath)) {
|
||||
$variantUrls[$key] = asset('storage/'.ltrim($relativePath, '/'));
|
||||
if (is_string($relativePath) && filled($relativePath) && filled($url = $this->variantUrl($key))) {
|
||||
$variantUrls[$key] = $url;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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\ClassificationResult;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -80,6 +81,13 @@ class ClassifyPressRelease implements ShouldQueue
|
|||
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText());
|
||||
} catch (BlacklistViolationException) {
|
||||
// publish() hat die PM bereits abgelehnt und den Autor benachrichtigt.
|
||||
} catch (QuotaExceededException $exception) {
|
||||
// Kontingent zwischen Einreichung und KI-Freigabe aufgebraucht: PM
|
||||
// bleibt in Review, damit Autor/Admin bewusst entscheiden — kein
|
||||
// Job-Retry, der die Klassifikation sinnlos wiederholt.
|
||||
Log::info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [
|
||||
'press_release_id' => $pressRelease->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class ScorePressRelease implements ShouldQueue
|
|||
'model' => $result->model,
|
||||
'result' => (string) $result->score,
|
||||
'reason' => $tier->label(),
|
||||
'raw_response' => $result->rawResponse,
|
||||
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,33 @@
|
|||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\EmailNotificationType;
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
use App\Enums\SinglePurchaseType;
|
||||
use App\Mail\BookingPaymentConfirmed;
|
||||
use App\Mail\PressReleaseDepublished;
|
||||
use App\Models\CreditTopup;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceBillingAddress;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Billing\CreditTopupService;
|
||||
use App\Services\Billing\InvoiceNumberGenerator;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
/**
|
||||
* Verarbeitet Stripe-Webhooks über das Cashier-Event (Phase 9E).
|
||||
|
|
@ -32,17 +47,151 @@ use Laravel\Cashier\Events\WebhookReceived;
|
|||
*/
|
||||
class ProcessStripeWebhook
|
||||
{
|
||||
public function __construct(private readonly InvoiceNumberGenerator $numbers) {}
|
||||
public function __construct(
|
||||
private readonly InvoiceNumberGenerator $numbers,
|
||||
private readonly ActivityLog $activityLog,
|
||||
) {}
|
||||
|
||||
public function handle(WebhookReceived $event): void
|
||||
{
|
||||
match ($event->payload['type'] ?? null) {
|
||||
'invoice.payment_succeeded' => $this->mirrorPaidInvoice($event->payload['data']['object'] ?? []),
|
||||
'checkout.session.completed' => $this->fulfillCheckout($event->payload['data']['object'] ?? []),
|
||||
'checkout.session.completed',
|
||||
'checkout.session.async_payment_succeeded' => $this->fulfillCheckout($event->payload['data']['object'] ?? []),
|
||||
'checkout.session.async_payment_failed' => $this->logFailedAsyncPayment($event->payload['data']['object'] ?? []),
|
||||
'customer.subscription.updated' => $this->handleSubscriptionUpdated($event->payload['data'] ?? []),
|
||||
'customer.subscription.deleted' => $this->recordSubscriptionEnded($event->payload['data']['object'] ?? []),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function handleSubscriptionUpdated(array $data): void
|
||||
{
|
||||
$this->clearAppliedScheduledChange($data['object'] ?? []);
|
||||
$this->recordCancellationChange($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kündigung zum Periodenende (bzw. deren Rücknahme) im Stripe-Kundenportal:
|
||||
* Cashier pflegt `ends_at`, hier kommt der Eintrag in den Aktivitäts-Verlauf.
|
||||
* Die Transition steckt in `previous_attributes.cancel_at_period_end`.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function recordCancellationChange(array $data): void
|
||||
{
|
||||
$subscription = $data['object'] ?? [];
|
||||
$previous = $data['previous_attributes'] ?? [];
|
||||
|
||||
if (! array_key_exists('cancel_at_period_end', $previous)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cancelled = (bool) ($subscription['cancel_at_period_end'] ?? false);
|
||||
|
||||
if ($cancelled === (bool) $previous['cancel_at_period_end']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = Cashier::findBillable($subscription['customer'] ?? null);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$endsAt = filled($subscription['cancel_at'] ?? null)
|
||||
? Carbon::createFromTimestamp((int) $subscription['cancel_at'])->timezone(config('app.timezone'))
|
||||
: null;
|
||||
|
||||
$description = $cancelled
|
||||
? sprintf(
|
||||
'Abo gekündigt — läuft am %s aus. %s (%s)',
|
||||
$endsAt?->format('d.m.Y') ?? 'Periodenende',
|
||||
$user->name,
|
||||
$user->email,
|
||||
)
|
||||
: sprintf('Abo-Kündigung zurückgenommen. %s (%s)', $user->name, $user->email);
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::SubscriptionChange,
|
||||
$description,
|
||||
user: $user,
|
||||
subject: $this->localSubscription($subscription['id'] ?? null),
|
||||
meta: array_filter([
|
||||
'stripe_subscription_id' => $subscription['id'] ?? null,
|
||||
'cancel_at' => $endsAt?->toDateTimeString(),
|
||||
'cancelled' => $cancelled,
|
||||
], fn ($value): bool => $value !== null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abo endgültig beendet (Periodenende erreicht oder Sofort-Kündigung).
|
||||
*
|
||||
* @param array<string, mixed> $subscription
|
||||
*/
|
||||
private function recordSubscriptionEnded(array $subscription): void
|
||||
{
|
||||
$user = Cashier::findBillable($subscription['customer'] ?? null);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::SubscriptionChange,
|
||||
sprintf('Abo beendet. %s (%s)', $user->name, $user->email),
|
||||
user: $user,
|
||||
subject: $this->localSubscription($subscription['id'] ?? null),
|
||||
meta: array_filter(['stripe_subscription_id' => $subscription['id'] ?? null]),
|
||||
);
|
||||
}
|
||||
|
||||
private function localSubscription(?string $stripeId): ?Subscription
|
||||
{
|
||||
return $stripeId
|
||||
? Subscription::query()->where('stripe_id', $stripeId)->first()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorgemerkter Tarifwechsel (Downgrade zum Periodenende): Sobald der
|
||||
* Stripe Subscription Schedule die neue Phase aktiviert, meldet
|
||||
* `customer.subscription.updated` den neuen Preis — die lokale
|
||||
* Vormerkung ist damit erledigt. Cashiers eigener Webhook-Handler
|
||||
* aktualisiert `stripe_price` im selben Request.
|
||||
*
|
||||
* @param array<string, mixed> $stripeSubscription
|
||||
*/
|
||||
private function clearAppliedScheduledChange(array $stripeSubscription): void
|
||||
{
|
||||
$stripeId = $stripeSubscription['id'] ?? null;
|
||||
$price = $stripeSubscription['items']['data'][0]['price']['id'] ?? null;
|
||||
|
||||
if (! $stripeId || ! $price) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cleared = Subscription::query()
|
||||
->where('stripe_id', $stripeId)
|
||||
->where('scheduled_stripe_price', $price)
|
||||
->update([
|
||||
'scheduled_stripe_price' => null,
|
||||
'scheduled_change_at' => null,
|
||||
'stripe_schedule_id' => null,
|
||||
]);
|
||||
|
||||
if ($cleared > 0) {
|
||||
Log::info('Vorgemerkter Tarifwechsel wurde von Stripe vollzogen.', [
|
||||
'stripe_subscription_id' => $stripeId,
|
||||
'stripe_price' => $price,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stripeInvoice
|
||||
*/
|
||||
|
|
@ -54,7 +203,6 @@ class ProcessStripeWebhook
|
|||
return;
|
||||
}
|
||||
|
||||
// Idempotent: Stripe liefert Webhooks mindestens einmal.
|
||||
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -71,28 +219,85 @@ class ProcessStripeWebhook
|
|||
}
|
||||
|
||||
$subtotal = (int) ($stripeInvoice['subtotal'] ?? 0);
|
||||
$tax = (int) ($stripeInvoice['tax'] ?? 0);
|
||||
// Steuer je nach Stripe-API-Generation: `tax` (alt), sonst Summe aus
|
||||
// `total_taxes` (aktuell) bzw. `total_tax_amounts` (Übergangsname).
|
||||
$tax = (int) ($stripeInvoice['tax']
|
||||
?? collect($stripeInvoice['total_taxes'] ?? $stripeInvoice['total_tax_amounts'] ?? [])->sum('amount'));
|
||||
$total = (int) ($stripeInvoice['total'] ?? $subtotal + $tax);
|
||||
|
||||
$invoice = Invoice::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id,
|
||||
'number' => $this->numbers->nextStripeNumber(),
|
||||
'status' => InvoiceStatus::Paid->value,
|
||||
'amount_cents' => $subtotal,
|
||||
'tax_cents' => $tax,
|
||||
'total_cents' => $total,
|
||||
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? 'eur')),
|
||||
'is_netto' => $tax === 0,
|
||||
'invoice_date' => now()->toDateString(),
|
||||
'paid_at' => now(),
|
||||
'stripe_invoice_id' => $stripeInvoiceId,
|
||||
]);
|
||||
// Ein Tarifwechsel nach unten erzeugt eine Proration-Gutschrift:
|
||||
// Stripe meldet sie als "bezahlte" Rechnung mit negativem Betrag,
|
||||
// das Guthaben verbleibt als Kundenkonto-Saldo und mindert künftige
|
||||
// Rechnungen. In den STR-Rechnungsausgang gehört so ein Beleg nicht
|
||||
// (QA-Befund 07.07.: unsigned-Spalten → Webhook-500) — der Beleg
|
||||
// selbst bleibt bei Stripe einsehbar.
|
||||
if ($total <= 0) {
|
||||
Log::info('STR-Spiegelung übersprungen: Stripe-Beleg ohne positiven Rechnungsbetrag (Gutschrift/Null-Rechnung).', [
|
||||
'stripe_invoice_id' => $stripeInvoiceId,
|
||||
'total_cents' => $total,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$invoice = DB::transaction(function () use ($user, $stripeInvoice, $stripeInvoiceId, $subtotal, $tax, $total): ?Invoice {
|
||||
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Invoice::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id,
|
||||
'number' => $this->numbers->nextStripeNumber(),
|
||||
'status' => InvoiceStatus::Paid->value,
|
||||
'amount_cents' => $subtotal,
|
||||
'tax_cents' => $tax,
|
||||
'total_cents' => $total,
|
||||
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? config('billing.default_currency'))),
|
||||
'is_netto' => $tax === 0,
|
||||
'invoice_date' => now()->toDateString(),
|
||||
'paid_at' => now(),
|
||||
'stripe_invoice_id' => $stripeInvoiceId,
|
||||
]);
|
||||
});
|
||||
} catch (UniqueConstraintViolationException $exception) {
|
||||
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($invoice === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [
|
||||
'number' => $invoice->number,
|
||||
'stripe_invoice_id' => $stripeInvoiceId,
|
||||
]);
|
||||
|
||||
// Zahlungsbestätigung an den Kunden — genau eine pro Beleg, da die
|
||||
// Spiegelung über stripe_invoice_id idempotent ist. Deckt alle
|
||||
// Buchungsarten ab (Tarif inkl. Verlängerung, Einzel-PM, Credits).
|
||||
if ($user->email) {
|
||||
Mail::to($user->email)->queue(new BookingPaymentConfirmed($user, $invoice));
|
||||
}
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::BookingPaid,
|
||||
sprintf(
|
||||
'Zahlung eingegangen: Rechnung %s über %s € — %s (%s).',
|
||||
$invoice->number,
|
||||
number_format($invoice->total_cents / 100, 2, ',', '.'),
|
||||
$user->name,
|
||||
$user->email,
|
||||
),
|
||||
user: $user,
|
||||
subject: $invoice,
|
||||
meta: ['invoice_number' => $invoice->number, 'total_cents' => $invoice->total_cents],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -127,10 +332,40 @@ class ProcessStripeWebhook
|
|||
*/
|
||||
private function fulfillCheckout(array $session): void
|
||||
{
|
||||
// Asynchrone Zahlarten (PayPal, SEPA …): `checkout.session.completed`
|
||||
// feuert dort schon nach dem Redirect mit payment_status=unpaid — das
|
||||
// Ergebnis kommt erst mit `checkout.session.async_payment_succeeded/
|
||||
// _failed`. Erfüllt wird deshalb nur bei bestätigter Zahlung; beide
|
||||
// Erfüllungspfade sind idempotent, doppelte Events bleiben folgenlos.
|
||||
if (! in_array($session['payment_status'] ?? null, ['paid', 'no_payment_required'], true)) {
|
||||
Log::info('Checkout-Session noch unbezahlt — Erfüllung wartet auf async_payment_succeeded.', [
|
||||
'session_id' => $session['id'] ?? null,
|
||||
'payment_status' => $session['payment_status'] ?? null,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->fulfillSinglePurchase($session);
|
||||
$this->fulfillCreditTopup($session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlgeschlagene asynchrone Zahlung: Kauf/Topup bleibt bewusst auf
|
||||
* Pending (nichts wurde erfüllt, der Kunde kann erneut buchen) — aber
|
||||
* die Betriebsspur muss sichtbar sein.
|
||||
*
|
||||
* @param array<string, mixed> $session
|
||||
*/
|
||||
private function logFailedAsyncPayment(array $session): void
|
||||
{
|
||||
Log::warning('Asynchrone Stripe-Zahlung fehlgeschlagen — keine Erfüllung.', [
|
||||
'session_id' => $session['id'] ?? null,
|
||||
'single_purchase_id' => $session['metadata']['single_purchase_id'] ?? null,
|
||||
'credit_topup_id' => $session['metadata']['credit_topup_id'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $session
|
||||
*/
|
||||
|
|
@ -168,19 +403,102 @@ class ProcessStripeWebhook
|
|||
return;
|
||||
}
|
||||
|
||||
$purchase = SinglePurchase::query()->find((int) $purchaseId);
|
||||
/** @var array{purchase: SinglePurchase, marked_paid: bool, depublished_release: PressRelease|null}|null $result */
|
||||
$result = DB::transaction(function () use ($purchaseId, $session): ?array {
|
||||
$purchase = SinglePurchase::query()
|
||||
->whereKey((int) $purchaseId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $purchase || $purchase->status !== SinglePurchaseStatus::Pending) {
|
||||
if (! $purchase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$markedPaid = false;
|
||||
|
||||
if ($purchase->status === SinglePurchaseStatus::Pending) {
|
||||
$purchase->update([
|
||||
'status' => SinglePurchaseStatus::Paid->value,
|
||||
'paid_at' => now(),
|
||||
'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id,
|
||||
'stripe_payment_intent_id' => $session['payment_intent'] ?? null,
|
||||
]);
|
||||
$markedPaid = true;
|
||||
} elseif (
|
||||
$purchase->type !== SinglePurchaseType::Depublication
|
||||
|| $purchase->status !== SinglePurchaseStatus::Paid
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'purchase' => $purchase,
|
||||
'marked_paid' => $markedPaid,
|
||||
'depublished_release' => $this->fulfillDepublication($purchase),
|
||||
];
|
||||
});
|
||||
|
||||
if ($result === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$purchase = $result['purchase'];
|
||||
|
||||
if ($result['marked_paid']) {
|
||||
Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
|
||||
}
|
||||
|
||||
$pressRelease = $result['depublished_release'];
|
||||
|
||||
if ($pressRelease === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$buyer = $purchase->user;
|
||||
|
||||
if ($buyer && $buyer->email && $buyer->wantsEmailNotification(EmailNotificationType::PressReleaseDepublished)) {
|
||||
Mail::to($buyer->email)->queue(new PressReleaseDepublished($buyer, $pressRelease));
|
||||
}
|
||||
|
||||
Log::info('Pressemitteilung depubliziert.', [
|
||||
'single_purchase_id' => $purchase->id,
|
||||
'press_release_id' => $pressRelease->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* T12: Nach bezahltem Depublikations-Kauf die PM sofort aus der
|
||||
* öffentlichen Ansicht nehmen (Tombstone bleibt). Replays sind über den
|
||||
* Pending-Status des Kaufs abgefangen; der Kauf gilt mit der
|
||||
* Depublikation als eingelöst.
|
||||
*/
|
||||
private function fulfillDepublication(SinglePurchase $purchase): ?PressRelease
|
||||
{
|
||||
if ($purchase->type !== SinglePurchaseType::Depublication) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pressRelease = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($purchase->press_release_id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $pressRelease || $pressRelease->status !== PressReleaseStatus::Published) {
|
||||
Log::warning('Depublikation bezahlt, aber PM nicht (mehr) veröffentlicht.', [
|
||||
'single_purchase_id' => $purchase->id,
|
||||
'press_release_id' => $purchase->press_release_id,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
app(PressReleaseService::class)->depublish($pressRelease);
|
||||
$purchase->update([
|
||||
'status' => SinglePurchaseStatus::Paid->value,
|
||||
'paid_at' => now(),
|
||||
'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id,
|
||||
'stripe_payment_intent_id' => $session['payment_intent'] ?? null,
|
||||
'status' => SinglePurchaseStatus::Consumed->value,
|
||||
'consumed_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
|
||||
return $pressRelease;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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