web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)

Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben

F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope
auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und
Medienpfade sowie die PressRelease-Helfer schreiben jetzt
withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht
kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit
Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter
und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst
verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein.
Der Vorschau-Test prüfte bisher forceDelete statt delete.

F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen
Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht
auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER.
Neuer Index contacts_email_idx.

F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei
Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die
Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt-
Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache
vorher nicht und traf den Fehler nie.

F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst
(ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung
gewinnt die Erstquelle.

F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss
aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren
Vertrag.

F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann
Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht
als verlorenes Logo.

Block 4 — KI-Prüfung und Scheduler

F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei
Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen
(ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler
veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum
aktuellen Text passt, sondern plant eine Prüfung mit Routing ein.

F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period).
Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der
Reset ist idempotent und lässt den laufenden Monat in Ruhe.

F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen
Fehler-Exit-Code, damit der Failure-Hook greift.

F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt.

F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 10:40:48 +00:00
parent 301bca3a90
commit 72432fdb82
73 changed files with 1746 additions and 170 deletions

View file

@ -8,6 +8,7 @@ use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
@ -40,7 +41,31 @@ class AggregatePressReleaseViews extends Command
*/
public const WatermarkKey = 'press_release_views.aggregated_through_id';
/**
* Cache-Lock über alle Einstiege (Scheduler UND manueller Aufruf): Zwei
* gleichzeitige Läufe lesen dieselbe Marke und verbuchen denselben Chunk
* doppelt (Audit F19). withoutOverlapping() schützt nur den Scheduler.
*/
public const LockKey = 'press-releases:aggregate-views';
public function handle(): int
{
$lock = Cache::lock(self::LockKey, 600);
if (! $lock->get()) {
$this->warn('Ein anderer Aggregationslauf ist noch aktiv – dieser Aufruf tut nichts.');
return self::SUCCESS;
}
try {
return $this->aggregate();
} finally {
$lock->release();
}
}
private function aggregate(): int
{
$chunk = max(100, (int) $this->option('chunk'));
$watermark = AppSetting::getValue(self::WatermarkKey);

View file

@ -10,6 +10,7 @@ use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Mail\EmailSuppressionService;
use App\Services\Newsletter\NewsletterSyncService;
use App\Support\EmailQuery;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
@ -128,7 +129,7 @@ class ImportNewsletterBounces extends Command
// Groß-/Kleinschreibung ausdrücklich ignorieren, wie in
// User::findByEmail(). Bounce-Listen kommen aus dem Mailserver-Log und
// geben die Adresse so wieder, wie der Absender sie geschrieben hat.
$users = User::query()->whereRaw('LOWER(email) = ?', [$email])->get();
$users = EmailQuery::whereEmail(User::query(), $email)->get();
foreach ($users as $user) {
if (! $user->wantsEmailNotification(EmailNotificationType::Newsletter)) {
@ -169,9 +170,7 @@ class ImportNewsletterBounces extends Command
*/
private function suppressSubscriptions(string $email, bool $dryRun, int &$unsubscribed, int &$alreadyUnsubscribed): bool
{
$subscriptions = NewsletterSubscription::query()
->withoutGlobalScopes()
->whereRaw('LOWER(email) = ?', [$email])
$subscriptions = EmailQuery::whereEmail(NewsletterSubscription::query()->withoutGlobalScopes(), $email)
->get();
foreach ($subscriptions as $subscription) {

View file

@ -4,6 +4,8 @@ namespace App\Console\Commands;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\BlacklistViolationException;
@ -23,6 +25,13 @@ use Throwable;
*
* Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit
* Mail-Benachrichtigung des Autors.
*
* Inhaltsbindung (Audit F07): Passt die jüngste Klassifikation nicht mehr
* zum aktuellen Text (Admin- oder Korrektur-Bearbeitung nach der Prüfung),
* wird nicht veröffentlicht, sondern eine Prüfung mit Routing eingeplant –
* Grün geht dann zum fälligen Termin live, Rot lehnt ab und benachrichtigt.
* Technische Fehler ergeben einen Fehler-Exit-Code, damit der Failure-Hook
* des Schedulers greift (Audit F20); fachliche Ablehnungen nicht.
*/
class PublishScheduledPressReleases extends Command
{
@ -75,6 +84,7 @@ class PublishScheduledPressReleases extends Command
$published = 0;
$rejected = 0;
$failed = 0;
$rechecked = 0;
foreach ($candidates as $pressRelease) {
$line = sprintf(
@ -90,6 +100,14 @@ class PublishScheduledPressReleases extends Command
continue;
}
if ($this->classificationIsStale($pressRelease)) {
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $pressRelease->content_hash)->onQueue('classification');
$rechecked++;
$this->warn($line.' [RECHECK: Inhalt nach der Prüfung geändert]');
continue;
}
try {
$service->publish($pressRelease, source: 'scheduler');
$published++;
@ -107,13 +125,32 @@ class PublishScheduledPressReleases extends Command
if (! $dryRun) {
$this->newLine();
$this->info(sprintf(
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d fehlgeschlagen.',
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d neu zur Prüfung, %d fehlgeschlagen.',
$published,
$rejected,
$rechecked,
$failed,
));
}
return self::SUCCESS;
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Jüngste gültige Klassifikation mit Prüfsumme, die nicht zum aktuellen
* Inhalt passt. Altbestand ohne Prüfsumme (vor der Bindung) gilt weiter.
*/
private function classificationIsStale(PressRelease $pressRelease): bool
{
$latestHash = KiAudit::query()
->where('press_release_id', $pressRelease->id)
->where('type', KiAudit::TYPE_CLASSIFICATION)
->whereNull('discarded_at')
->orderByDesc('id')
->value('content_hash');
return $latestHash !== null
&& $pressRelease->content_hash !== null
&& $latestHash !== $pressRelease->content_hash;
}
}

View file

@ -14,6 +14,12 @@ use Illuminate\Console\Command;
* per Scheduler am Monatsersten (siehe routes/console.php). Bewusste
* Vereinfachung: Reset am Kalendermonat, nicht an der individuellen
* Abo-Periode ausgerichtet.
*
* Idempotent und periodengebunden (Audit F18): zurückgesetzt wird nur, was
* zu einem früheren Monat gehört (oder noch keinen Monat trägt). Verbrauch
* des laufenden Monats bleibt – ein doppelter oder verspäteter Lauf ist
* damit harmlos. Die fachliche Wahrheit liegt ohnehin in
* User::pressReleaseQuotaUsedThisMonth(), der Befehl räumt nur auf.
*/
class ResetMonthlyPressReleaseQuota extends Command
{
@ -29,9 +35,17 @@ class ResetMonthlyPressReleaseQuota extends Command
public function handle(): int
{
$period = User::currentQuotaPeriod();
$affected = User::query()
->where('press_release_quota_used_this_month', '>', 0)
->update(['press_release_quota_used_this_month' => 0]);
->where(fn ($query) => $query
->whereNull('press_release_quota_period')
->orWhere('press_release_quota_period', '<', $period))
->update([
'press_release_quota_used_this_month' => 0,
'press_release_quota_period' => $period,
]);
$this->info(sprintf('Kontingent-Verbrauch für %d User zurückgesetzt.', $affected));

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Rules\PlausibleEmailDomain;
use App\Scopes\PortalScope;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@ -22,7 +23,7 @@ class LegacyContactController extends Controller
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
$company = Company::withoutGlobalScopes()
$company = Company::withoutGlobalScope(PortalScope::class)
->whereKey($company)
->accessibleBy($request->user())
->first();

View file

@ -70,7 +70,7 @@ class LegacyPressReleaseController extends Controller
$user = $request->user();
$company = Company::withoutGlobalScopes()
$company = Company::withoutGlobalScope(PortalScope::class)
->whereKey((int) $validated['company_id'])
->accessibleBy($user)
->first();
@ -81,8 +81,8 @@ class LegacyPressReleaseController extends Controller
// 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']);
$category = Category::withoutGlobalScope(PortalScope::class)->where('legacy_id', (int) $validated['category_id'])->first()
?? Category::withoutGlobalScope(PortalScope::class)->find((int) $validated['category_id']);
if (! $category) {
return $this->legacyError(422, ['Unknown category.']);

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiImageDeclaration;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -26,7 +27,7 @@ class LegacyPressReleaseImageController extends Controller
{
abort_unless($request->user()->tokenCan('press-release-images:write'), 403);
$pressRelease = PressRelease::withoutGlobalScopes()->find($pressRelease);
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->find($pressRelease);
if (! $pressRelease) {
return $this->legacyError(404, [sprintf('Object press_release does not exist (%s).', $request->route('pressRelease'))]);

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
use App\Scopes\PortalScope;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@ -14,10 +15,16 @@ class CompanyController extends Controller
{
abort_unless($request->user()->tokenCan('companies:read'), 403);
$companies = Company::withoutGlobalScopes()
// Untergrenze wie Obergrenze (Audit F11): ein negatives per_page hob
// das SQL-Limit auf.
$filters = $request->validate([
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$companies = Company::withoutGlobalScope(PortalScope::class)
->accessibleBy($request->user())
->orderBy('name')
->paginate(min((int) $request->query('per_page', 25), 100));
->paginate((int) ($filters['per_page'] ?? 25));
return CompanyResource::collection($companies);
}
@ -31,7 +38,7 @@ class CompanyController extends Controller
{
abort_unless($request->user()->tokenCan('companies:read'), 403);
$company = Company::withoutGlobalScopes()
$company = Company::withoutGlobalScope(PortalScope::class)
->whereKey($company)
->accessibleBy($request->user())
->first();

View file

@ -10,8 +10,10 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
use App\Http\Resources\PressReleaseResource;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
@ -24,6 +26,7 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class PressReleaseController extends Controller
{
@ -33,12 +36,20 @@ class PressReleaseController extends Controller
{
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
$pressReleases = PressRelease::withoutGlobalScopes()
// Listenparameter werden validiert statt durchgereicht (Audit F11):
// ein negatives per_page hob das SQL-Limit auf, ein Array als status
// lief in einen TypeError.
$filters = $request->validate([
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'status' => ['sometimes', 'string', Rule::enum(PressReleaseStatus::class)],
]);
$pressReleases = PressRelease::withoutGlobalScope(PortalScope::class)
->where('user_id', $request->user()->id)
->with($this->apiResourceRelations())
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
->when($filters['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))
->latest()
->paginate(min((int) $request->query('per_page', 25), 100));
->paginate((int) ($filters['per_page'] ?? 25));
return PressReleaseResource::collection($pressReleases);
}
@ -52,6 +63,8 @@ class PressReleaseController extends Controller
// Ziel-Portal: expliziter Parameter > Request-Domain > Firmen-Portal.
$portal = $this->resolveRequestedPortal($validated['portal'] ?? null, $company->portal)->value;
$this->assertCategoryFitsPortal((int) $validated['category_id'], $portal, $request);
$pressRelease = new PressRelease([
...$validated,
'uuid' => (string) Str::uuid(),
@ -122,6 +135,10 @@ class PressReleaseController extends Controller
?? $pressRelease->portal
?? $company->portal)->value;
if (isset($validated['category_id'])) {
$this->assertCategoryFitsPortal((int) $validated['category_id'], $portal, $request);
}
$pressRelease->fill([
...$validated,
'portal' => $portal,
@ -233,9 +250,34 @@ class PressReleaseController extends Controller
return response()->noContent();
}
/**
* Die Kategorie muss aktiv sein und zum Ziel-Portal passen (Audit F11):
* `exists:categories,id` allein ließ inaktive und portalfremde Kategorien
* durch. Legacy-Aliasse (/service/…) lösen ihre Kategorie über legacy_id
* im eigenen Controller auf und bleiben hier außen vor.
*/
private function assertCategoryFitsPortal(int $categoryId, string $portal, Request $request): void
{
if ($this->isLegacyServiceRequest($request)) {
return;
}
$fits = Category::query()
->whereKey($categoryId)
->where('is_active', true)
->whereIn('portal', [$portal, Portal::Both->value])
->exists();
if (! $fits) {
throw ValidationException::withMessages([
'category_id' => 'Die Kategorie ist inaktiv oder gehört nicht zum Ziel-Portal.',
]);
}
}
private function findOwnedCompany(int $companyId, Request $request): ?Company
{
return Company::withoutGlobalScopes()
return Company::withoutGlobalScope(PortalScope::class)
->whereKey($companyId)
->accessibleBy($request->user())
->first();
@ -254,7 +296,7 @@ class PressReleaseController extends Controller
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
{
return PressRelease::withoutGlobalScopes()
return PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressReleaseId)
->ownedByApiUser($request->user())
->first();

View file

@ -9,6 +9,7 @@ use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
use App\Http\Resources\PressReleaseImageResource;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -89,8 +90,8 @@ class PressReleaseImageController extends Controller
$image = PressReleaseImage::query()
->whereKey($pressReleaseImage)
->whereHas('pressRelease', fn ($query) => $query->withoutGlobalScopes()->where('user_id', $request->user()->id))
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScopes()])
->whereHas('pressRelease', fn ($query) => $query->withoutGlobalScope(PortalScope::class)->where('user_id', $request->user()->id))
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScope(PortalScope::class)])
->first();
abort_unless($image !== null, 403);
@ -109,7 +110,7 @@ class PressReleaseImageController extends Controller
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
{
return PressRelease::withoutGlobalScopes()
return PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressReleaseId)
->ownedByApiUser($request->user())
->first();

View file

@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Http\RedirectResponse;
@ -113,7 +114,7 @@ class MagicLinkConsumeController extends Controller
$landingPressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
if ($landingPressReleaseId > 0) {
$pressRelease = PressRelease::query()->withoutGlobalScopes()->whereKey($landingPressReleaseId)->where('company_id', $landingCompanyId)->first();
$pressRelease = PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($landingPressReleaseId)->where('company_id', $landingCompanyId)->first();
if ($pressRelease !== null && $user->can('view', $pressRelease)) {
$home = route('me.press-releases.show', ['id' => $landingPressReleaseId], absolute: false);

View file

@ -6,6 +6,7 @@ use App\Enums\Portal;
use App\Http\Middleware\SetEdition;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\DomainAssetContext;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
@ -30,7 +31,7 @@ class PressReleasePreviewController extends Controller
$pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
$pressRelease = $pressReleaseId
? PressRelease::withoutGlobalScopes()
? PressRelease::withoutGlobalScope(PortalScope::class)
->with([
'company',
'user:id,agency_name',

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Billing\ProofPdfService;
use App\Services\PressRelease\ProofPdfRenderer;
use Illuminate\Support\Facades\Gate;
@ -20,7 +21,7 @@ class ProofPdfController extends Controller
ProofPdfService $service,
ProofPdfRenderer $renderer,
): Response {
$pressRelease = PressRelease::query()->withoutGlobalScopes()->findOrFail((int) $id);
$pressRelease = PressRelease::query()->withoutGlobalScope(PortalScope::class)->findOrFail((int) $id);
Gate::authorize('downloadProof', $pressRelease);

View file

@ -89,7 +89,7 @@ class NewsroomClaimController extends Controller
return null;
}
return PressRelease::query()->withoutGlobalScopes()->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null;
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null;
}
private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AuthorizesPressReleaseMediaAccess;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use App\Scopes\PortalScope;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -18,8 +19,8 @@ class PressReleaseAttachmentMediaController extends Controller
{
$attachment = PressReleaseAttachment::query()
->with(['pressRelease' => fn ($query) => $query
->withoutGlobalScopes()
->with(['company' => fn ($company) => $company->withoutGlobalScopes()])])
->withoutGlobalScope(PortalScope::class)
->with(['company' => fn ($company) => $company->withoutGlobalScope(PortalScope::class)])])
->findOrFail($attachment);
$pressRelease = $attachment->pressRelease;

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AuthorizesPressReleaseMediaAccess;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -18,8 +19,8 @@ class PressReleaseImageMediaController extends Controller
{
$image = PressReleaseImage::query()
->with(['pressRelease' => fn ($query) => $query
->withoutGlobalScopes()
->with(['company' => fn ($company) => $company->withoutGlobalScopes()])])
->withoutGlobalScope(PortalScope::class)
->with(['company' => fn ($company) => $company->withoutGlobalScope(PortalScope::class)])])
->findOrFail($image);
$pressRelease = $image->pressRelease;

View file

@ -5,7 +5,6 @@ namespace App\Http\Controllers\Web;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
@ -16,6 +15,7 @@ use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\PressRelease\AnonymizationService;
use App\Services\Web\ReportedUrlResolver;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -36,6 +36,8 @@ class PressReleaseReportController extends Controller
{
private const SESSION_KEY = 'pm_report.verified';
public function __construct(private readonly ReportedUrlResolver $reportedUrls) {}
/** @var array<string, LegalRequestType> */
public const CONCERNS = [
'correction' => LegalRequestType::Correction,
@ -63,7 +65,7 @@ class PressReleaseReportController extends Controller
$this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600);
$pressRelease = $this->matchPublishedRelease($validated['reported_url']);
$pressRelease = $this->reportedUrls->resolve($validated['reported_url']);
if (! $pressRelease) {
throw ValidationException::withMessages([
@ -322,19 +324,6 @@ class PressReleaseReportController extends Controller
];
}
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();
}
/**
* „kontakt@firma.de" → „k***@firma.de" — die Adresse selbst wird nie
* angezeigt und nie aus dem Formular übernommen.

View file

@ -4,13 +4,11 @@ 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\Services\Web\ReportedUrlResolver;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -31,6 +29,8 @@ class ReportFormController extends Controller
{
public const WAYS = ['rechtswidrig', 'kontaktdaten'];
public function __construct(private readonly ReportedUrlResolver $reportedUrls) {}
public function show(string $edition, string $way): View
{
return view('web.melden-form', ['way' => $way]);
@ -59,7 +59,10 @@ class ReportFormController extends Controller
$this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600);
$release = $this->matchPublishedRelease($validated['reported_url']);
// Zuordnung zu einer PM nur, wenn die URL einer Detail-Adresse eines
// Portals entspricht — sonst bleibt die Meldung ohne PM-Bezug in der
// Queue (URL steht im payload).
$release = $this->reportedUrls->resolve($validated['reported_url']);
$legalRequest = LegalRequest::query()->create([
'press_release_id' => $release?->getKey(),
@ -94,24 +97,6 @@ class ReportFormController extends Controller
]));
}
/**
* 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();

View file

@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\StaticPageCanonical;
use Closure;
use Illuminate\Http\Request;
@ -71,12 +72,12 @@ class RedirectPortalOnlyPagesFromHub
// dann gewinnt die Erstquelle (ohne `canonical_press_release_id`),
// damit die Weiterleitung nicht auf der Kopie endet.
$portal = $routeName === 'release.detail'
? PressRelease::query()->withoutGlobalScopes()
? PressRelease::query()->withoutGlobalScope(PortalScope::class)
->where('slug', $slug)
->orderByRaw('canonical_press_release_id is null desc')
->orderBy('id')
->value('portal')
: Company::query()->withoutGlobalScopes()->where('slug', $slug)->orderBy('id')->value('portal');
: Company::query()->withoutGlobalScope(PortalScope::class)->where('slug', $slug)->orderBy('id')->value('portal');
return $portal === null ? null : StaticPageCanonical::portalBaseFor((string) ($portal->value ?? $portal));
}

View file

@ -28,7 +28,7 @@ class StorePressReleaseRequest extends FormRequest
'portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])],
'company_id' => ['required', 'integer', 'exists:companies,id'],
'category_id' => ['required', 'integer', 'exists:categories,id'],
'language' => ['required', 'string', 'size:2'],
'language' => ['required', 'string', $this->languageRule()],
'title' => ['required', 'string', 'max:255'],
'text' => ['required', 'string'],
'backlink_url' => ['nullable', 'url', 'max:255'],
@ -38,4 +38,16 @@ class StorePressReleaseRequest extends FormRequest
'no_export' => ['nullable', 'boolean'],
];
}
/**
* Nur die ausgelieferten Ausgaben `de` und `en` (Audit F11) – ein
* beliebiger Zweizeichen-Code landete sonst im Unique-Schlüssel der PM.
* Legacy-Aliasse (/service/…) behalten den alten Vertrag.
*/
private function languageRule(): ValidationRule|string
{
return str_starts_with((string) $this->route()?->getName(), 'legacy-service.')
? 'size:2'
: Rule::in(['de', 'en']);
}
}

View file

@ -28,7 +28,7 @@ class UpdatePressReleaseRequest extends FormRequest
'portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])],
'company_id' => ['sometimes', 'required', 'integer', 'exists:companies,id'],
'category_id' => ['sometimes', 'required', 'integer', 'exists:categories,id'],
'language' => ['sometimes', 'required', 'string', 'size:2'],
'language' => ['sometimes', 'required', 'string', $this->languageRule()],
'title' => ['sometimes', 'required', 'string', 'max:255'],
'text' => ['sometimes', 'required', 'string'],
'backlink_url' => ['nullable', 'url', 'max:255'],
@ -38,4 +38,16 @@ class UpdatePressReleaseRequest extends FormRequest
'no_export' => ['nullable', 'boolean'],
];
}
/**
* Nur die ausgelieferten Ausgaben `de` und `en` (Audit F11) – ein
* beliebiger Zweizeichen-Code landete sonst im Unique-Schlüssel der PM.
* Legacy-Aliasse (/service/…) behalten den alten Vertrag.
*/
private function languageRule(): ValidationRule|string
{
return str_starts_with((string) $this->route()?->getName(), 'legacy-service.')
? 'size:2'
: Rule::in(['de', 'en']);
}
}

View file

@ -4,6 +4,7 @@ namespace App\Jobs;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
@ -25,6 +26,12 @@ use Illuminate\Support\Facades\Log;
* Das Ergebnis wird gespeichert/auditiert und anschließend über
* PressReleaseService::routeByClassification() in den Status überführt
* (Rot→rejected, Gelb→review, Grün→publish).
*
* Inhaltsbindung (Audit F07): Der Job trägt die Prüfsumme des Inhalts zum
* Zeitpunkt der Einplanung. Hat sich der Text während des KI-Aufrufs
* geändert, wird das Ergebnis verworfen (Audit mit `discarded_at`) und ein
* neuer Lauf für den aktuellen Stand eingeplant – ein altes Urteil darf
* weder eine neue Version bewerten noch freigeben.
*/
class ClassifyPressRelease implements ShouldQueue
{
@ -32,6 +39,12 @@ class ClassifyPressRelease implements ShouldQueue
public int $tries = 3;
/**
* Drei HTTP-Versuche à Timeout plus Verbindungsaufbau überschreiten die
* 60 s des Workers; retry_after (config/queue.php) liegt darüber.
*/
public int $timeout = 120;
/**
* @param int $pressReleaseId Zu prüfende PM
* @param bool $route Ob das Ergebnis den Status steuert (Rot→reject,
@ -40,23 +53,51 @@ class ClassifyPressRelease implements ShouldQueue
* klassifizieren/auditieren, Status unverändert).
* @param string|null $providerOverride Optionaler abweichender Treiber
* (z. B. 'openai'|'deterministic') für diesen Lauf.
* @param string|null $contentHash Prüfsumme des Inhalts bei Einplanung;
* null → wird beim Start des Jobs gelesen
* (Admin-Re-Checks, Altbestand in der Queue).
*/
public function __construct(
public readonly int $pressReleaseId,
public readonly bool $route = true,
public readonly ?string $providerOverride = null,
public readonly ?string $contentHash = null,
) {}
public function handle(ClassificationManager $manager, PressReleaseService $service): void
{
$pressRelease = PressRelease::withoutGlobalScopes()->find($this->pressReleaseId);
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->find($this->pressReleaseId);
if ($pressRelease === null) {
return;
}
$analyzedHash = $this->contentHash ?? $pressRelease->content_hash;
$result = $this->classify($manager, $pressRelease);
// Inhalt seit der Einplanung geändert? Dann gilt das Urteil für einen
// Text, den es nicht mehr gibt: verwerfen und den aktuellen prüfen.
$currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->whereKey($pressRelease->id)->value('content_hash');
if ($analyzedHash !== null && $currentHash !== null && $currentHash !== $analyzedHash) {
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'provider' => $result->provider,
'model' => $result->model,
'result' => $result->classification->value,
'reason' => 'Verworfen: Inhalt wurde während der Prüfung geändert. '.$result->reasonText(),
'raw_response' => $result->rawResponse,
'content_hash' => $analyzedHash,
'discarded_at' => now(),
'created_at' => now(),
]);
self::dispatch($pressRelease->id, $this->route, $this->providerOverride, $currentHash)->onQueue('classification');
return;
}
$pressRelease->forceFill([
'classification' => $result->classification->value,
'classified_at' => now(),
@ -70,6 +111,7 @@ class ClassifyPressRelease implements ShouldQueue
'result' => $result->classification->value,
'reason' => $result->reasonText(),
'raw_response' => $result->rawResponse,
'content_hash' => $analyzedHash,
'created_at' => now(),
]);

View file

@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Enums\PressReleaseContentTier;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\ContentScore\ContentScoreManager;
use App\Services\PressRelease\ContentScore\ContentScoreResult;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -21,6 +22,9 @@ use Illuminate\Support\Facades\Log;
*
* Reine Qualitätsbewertung – ändert nie den Status (das macht die
* Klassifikation).
*
* Inhaltsbindung wie bei ClassifyPressRelease (Audit F07): Ergebnis für
* einen inzwischen geänderten Text wird verworfen und neu eingeplant.
*/
class ScorePressRelease implements ShouldQueue
{
@ -28,22 +32,52 @@ class ScorePressRelease implements ShouldQueue
public int $tries = 3;
/**
* Drei HTTP-Versuche à 30 s plus Verbindungsaufbau (~91 s) überschreiten
* die 60 s des Workers – der deterministische Fallback käme sonst nie
* zum Zug (Audit F17). retry_after (config/queue.php) liegt darüber.
*/
public int $timeout = 120;
public function __construct(
public readonly int $pressReleaseId,
public readonly ?string $providerOverride = null,
public readonly ?string $contentHash = null,
) {}
public function handle(ContentScoreManager $manager): void
{
$pressRelease = PressRelease::withoutGlobalScopes()->find($this->pressReleaseId);
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->find($this->pressReleaseId);
if ($pressRelease === null) {
return;
}
$analyzedHash = $this->contentHash ?? $pressRelease->content_hash;
$result = $this->score($manager, $pressRelease);
$tier = PressReleaseContentTier::fromScore($result->score);
$currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->whereKey($pressRelease->id)->value('content_hash');
if ($analyzedHash !== null && $currentHash !== null && $currentHash !== $analyzedHash) {
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CONTENT_SCORE,
'provider' => $result->provider,
'model' => $result->model,
'result' => (string) $result->score,
'reason' => 'Verworfen: Inhalt wurde während der Bewertung geändert.',
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
'content_hash' => $analyzedHash,
'discarded_at' => now(),
'created_at' => now(),
]);
self::dispatch($pressRelease->id, $this->providerOverride, $currentHash)->onQueue('classification');
return;
}
$pressRelease->forceFill([
'content_score' => $result->score,
'content_tier' => $tier->value,
@ -58,6 +92,7 @@ class ScorePressRelease implements ShouldQueue
'result' => (string) $result->score,
'reason' => $tier->label(),
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
'content_hash' => $analyzedHash,
'created_at' => now(),
]);
}

View file

@ -29,7 +29,7 @@ class Contact extends Model
*/
public function scopeForCompany(Builder $query, int $companyId): Builder
{
return $query->withoutGlobalScopes()
return $query->withoutGlobalScope(PortalScope::class)
->where('company_id', $companyId)
->orderBy('last_name')
->orderBy('first_name');

View file

@ -13,6 +13,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* Schreibt für jede Bewertung einen unveränderlichen Eintrag inkl.
* Anbieter, Modell, Ergebnis, Begründung und Roh-Antwort – für
* Nachvollziehbarkeit und Nachweispflicht (DSGVO).
*
* `content_hash` bindet das Ergebnis an die geprüfte Inhaltsversion
* (Audit F07); `discarded_at` markiert Ergebnisse, die beim Eintreffen nicht
* mehr zum aktuellen Inhalt passten und deshalb nicht übernommen wurden.
*/
class KiAudit extends Model
{
@ -33,6 +37,8 @@ class KiAudit extends Model
'result',
'reason',
'raw_response',
'content_hash',
'discarded_at',
'created_at',
];
@ -41,6 +47,7 @@ class KiAudit extends Model
return [
'raw_response' => 'array',
'created_at' => 'datetime',
'discarded_at' => 'datetime',
];
}

View file

@ -191,7 +191,7 @@ class PressRelease extends Model
public function pressContactEmail(): ?string
{
$contactEmail = $this->contacts()
->withoutGlobalScopes()
->withoutGlobalScope(PortalScope::class)
->whereNotNull('email')
->where('email', '!=', '')
->value('email');
@ -204,7 +204,7 @@ class PressRelease extends Model
// ohne PM-Kontakt zählt der Firmen-Pressekontakt als Kanal.
if ($this->company_id) {
$companyContactEmail = Contact::query()
->withoutGlobalScopes()
->withoutGlobalScope(PortalScope::class)
->where('company_id', $this->company_id)
->whereNotNull('email')
->where('email', '!=', '')
@ -216,7 +216,7 @@ class PressRelease extends Model
}
}
$companyEmail = $this->company()->withoutGlobalScopes()->value('email');
$companyEmail = $this->company()->withoutGlobalScope(PortalScope::class)->value('email');
return is_string($companyEmail) && $companyEmail !== '' ? $companyEmail : null;
}
@ -427,7 +427,7 @@ class PressRelease extends Model
*/
public function canonical(): BelongsTo
{
return $this->belongsTo(PressRelease::class, 'canonical_press_release_id')->withoutGlobalScopes();
return $this->belongsTo(PressRelease::class, 'canonical_press_release_id')->withoutGlobalScope(PortalScope::class);
}
/**
@ -437,7 +437,7 @@ class PressRelease extends Model
*/
public function duplicates(): HasMany
{
return $this->hasMany(PressRelease::class, 'canonical_press_release_id')->withoutGlobalScopes();
return $this->hasMany(PressRelease::class, 'canonical_press_release_id')->withoutGlobalScope(PortalScope::class);
}
public function isCanonicalDuplicate(): bool
@ -727,7 +727,7 @@ class PressRelease extends Model
// EXISTS). Company::accessibleBy() (Owner ODER Pivot-Mitglied) ist die
// Single Source of Truth; als Eloquent-Subquery werden die Company-
// Global-Scopes (PortalScope, SoftDeletes) mitkompiliert.
return $query->withoutGlobalScopes()
return $query->withoutGlobalScope(PortalScope::class)
->where(function (Builder $q) use ($user): void {
$q->where('user_id', $user->id)
->orWhereIn('company_id', Company::accessibleBy($user)->select('companies.id'));
@ -752,7 +752,7 @@ class PressRelease extends Model
public static function accessibleStatusCountsFor(User $user, ?Closure $constrain = null): array
{
$branch = function (Closure $where) use ($constrain): Builder {
$query = static::query()->withoutGlobalScopes()->select('id', 'status');
$query = static::query()->withoutGlobalScope(PortalScope::class)->select('id', 'status');
$where($query);
if ($constrain !== null) {
@ -806,7 +806,7 @@ class PressRelease extends Model
$sortDir = $sortDir === 'asc' ? 'asc' : 'desc';
$branch = function (Closure $where) use ($constrain): Builder {
$query = static::query()->withoutGlobalScopes()
$query = static::query()->withoutGlobalScope(PortalScope::class)
->select('id', 'created_at', 'title', 'status');
$where($query);
@ -834,7 +834,7 @@ class PressRelease extends Model
->forPage($page, $perPage)
->pluck('id');
$models = static::query()->withoutGlobalScopes()
$models = static::query()->withoutGlobalScope(PortalScope::class)
->whereKey($pageIds)
// Kategorie fürs Listen-Rendering (localizedCategoryName, 13.07.);
// Bilder fürs Listen-Thumbnail — Titelbild zuerst (is_preview,

View file

@ -6,6 +6,7 @@ use App\Enums\EmailNotificationType;
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Enums\Tier;
use App\Support\EmailQuery;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -52,6 +53,7 @@ class User extends Authenticatable implements MustVerifyEmail
'oauth_provider',
'oauth_provider_id',
'press_release_quota_used_this_month',
'press_release_quota_period',
];
/**
@ -171,10 +173,9 @@ class User extends Authenticatable implements MustVerifyEmail
* Truth für die E-Mail-basierte Auflösung (Magic-Link, Social-Login,
* Kontakt-Zugang).
*
* Bewusst weiterhin über `LOWER(email)`: E-Mails werden nicht normalisiert
* gespeichert, daher ist der case-insensitive Vergleich Pflicht. Ein
* indexfreundlicher Direktvergleich setzt eine Normalisierung beim
* Schreiben voraus (separater, datenseitiger Schritt).
* Case-insensitiv über den Unique-Index (siehe EmailQuery): die Kollation
* der Spalte vergleicht ohne Groß-/Kleinschreibung, `LOWER()` würde den
* Index nur ausschalten (Audit F22).
*/
public static function findByEmail(string $email): ?self
{
@ -184,7 +185,7 @@ class User extends Authenticatable implements MustVerifyEmail
return null;
}
return static::query()->whereRaw('LOWER(email) = ?', [$email])->first();
return EmailQuery::whereEmail(static::query(), $email)->first();
}
/**
@ -270,12 +271,34 @@ class User extends Authenticatable implements MustVerifyEmail
$planRemaining = max(
0,
($this->currentPlan()?->press_release_quota ?? 0) - (int) $this->press_release_quota_used_this_month,
($this->currentPlan()?->press_release_quota ?? 0) - $this->pressReleaseQuotaUsedThisMonth(),
);
return $planRemaining + $this->openSinglePurchaseCount();
}
/**
* Kalendermonat, für den der Verbrauchszähler gilt (YYYY-MM).
*/
public static function currentQuotaPeriod(): string
{
return now()->format('Y-m');
}
/**
* Verbrauch des LAUFENDEN Monats. Steht der Zähler auf einem früheren
* Monat (oder ohne Monat aus der Zeit vor der Periodenbindung), zählt er
* null – unabhängig davon, ob der Reset-Cron gelaufen ist (Audit F18).
*/
public function pressReleaseQuotaUsedThisMonth(): int
{
if ($this->press_release_quota_period !== null && $this->press_release_quota_period !== self::currentQuotaPeriod()) {
return 0;
}
return (int) $this->press_release_quota_used_this_month;
}
/**
* Bezahlte, noch nicht eingelöste Einmalkäufe (Einzel-/Extra-PM), die zur
* Einreichung berechtigen. Per-Request memoisiert, weil das Status-Widget

View file

@ -0,0 +1,50 @@
<?php
namespace App\Observers;
use App\Models\Company;
use App\Support\WebCaches;
/**
* Verwirft die öffentlichen Seiten-Caches, sobald sich die Sichtbarkeit einer
* Firma ändert: Löschung, Wiederherstellung, Wechsel von `is_active`.
*
* Am Model statt im Admin-Formular: Löschen und Deaktivieren passieren auch
* aus Importern, Zusammenführungen und Konsolen-Befehlen — jeder Weg soll
* denselben Flush bekommen wie der 410-Weg im CompanyRemovalService. Sonst
* bleibt ein gecachter Newsroom bis zu eine Stunde erreichbar (F13).
*/
class CompanyPublicCacheObserver
{
public function updated(Company $company): void
{
// Nur Sichtbarkeitswechsel: Ein Flush verwirft ALLE Seiten-Caches und
// zwingt die gesamte öffentliche Seite in den Neuaufbau — das darf
// eine korrigierte Telefonnummer nicht auslösen.
if (! $company->wasChanged('is_active')) {
return;
}
// Der 410-Weg setzt `is_active` zusammen mit `gone_at` und verwirft
// die Caches selbst, einmal am Ende — auch nach einer Kaskade über
// alle Meldungen. Ein zweiter Flush hier wäre ein weiterer Stampede.
if ($company->wasChanged('gone_at')) {
return;
}
WebCaches::forgetPublicPages();
}
/**
* Feuert für Soft Delete und forceDelete gleichermaßen.
*/
public function deleted(Company $company): void
{
WebCaches::forgetPublicPages();
}
public function restored(Company $company): void
{
WebCaches::forgetPublicPages();
}
}

View file

@ -4,6 +4,7 @@ namespace App\Policies;
use App\Models\Company;
use App\Models\User;
use App\Scopes\PortalScope;
class CompanyPolicy
{
@ -57,7 +58,7 @@ class CompanyPolicy
private function isLinked(User $user, Company $company): bool
{
return $user->companies()->withoutGlobalScopes()->whereKey($company->id)->exists()
return $user->companies()->withoutGlobalScope(PortalScope::class)->whereKey($company->id)->exists()
|| $company->owner_user_id === $user->id;
}
@ -68,7 +69,7 @@ class CompanyPolicy
}
return $user->companies()
->withoutGlobalScopes()
->withoutGlobalScope(PortalScope::class)
->whereKey($company->id)
->wherePivotIn('role', ['owner', 'responsible'])
->exists();

View file

@ -30,6 +30,7 @@ use App\Models\User;
use App\Models\WithdrawalRequest;
use App\Observers\ActivityLogObserver;
use App\Observers\AdminPerformanceCacheObserver;
use App\Observers\CompanyPublicCacheObserver;
use App\Observers\EmailDomainObserver;
use App\Observers\PressReleaseSearchObserver;
use App\Services\Admin\AdminRequestPerformanceMetrics;
@ -220,6 +221,10 @@ class AppServiceProvider extends ServiceProvider
Company::observe(EmailDomainObserver::class);
Contact::observe(EmailDomainObserver::class);
// Öffentliche Seiten-Caches bei Löschung/Deaktivierung einer Firma
// verwerfen — auf jedem Löschweg, nicht nur im Admin-Formular (F13).
Company::observe(CompanyPublicCacheObserver::class);
AdminPreset::observe(AdminPerformanceCacheObserver::class);
Category::observe(AdminPerformanceCacheObserver::class);
CategoryTranslation::observe(AdminPerformanceCacheObserver::class);

View file

@ -8,6 +8,7 @@ use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Support\EmailQuery;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
@ -155,15 +156,14 @@ class ContactAccessService
}
/**
* Hinterlegte Pressekontakte (mit Firma) zu einer E-Mail – case-insensitive,
* portalübergreifend (kein PortalScope).
* Hinterlegte Pressekontakte (mit Firma) zu einer E-Mail – case-insensitive
* über den Index (EmailQuery), portalübergreifend (kein PortalScope).
*
* @return Collection<int, Contact>
*/
private function contactsFor(string $email): Collection
{
return Contact::withoutGlobalScope(PortalScope::class)
->whereRaw('LOWER(email) = ?', [$email])
return EmailQuery::whereEmail(Contact::withoutGlobalScope(PortalScope::class), $email)
->whereNotNull('company_id')
->get();
}
@ -176,8 +176,7 @@ class ContactAccessService
*/
private function companiesFor(string $email): Collection
{
return Company::withoutGlobalScope(PortalScope::class)
->whereRaw('LOWER(email) = ?', [$email])
return EmailQuery::whereEmail(Company::withoutGlobalScope(PortalScope::class), $email)
->get();
}

View file

@ -5,6 +5,7 @@ namespace App\Services\Customer;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\User;
use App\Scopes\PortalScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
@ -20,13 +21,13 @@ class CustomerCompanyContext
public function companiesFor(User $user): Collection
{
$linkedCompanies = $user->companies()
->withoutGlobalScopes()
->withoutGlobalScope(PortalScope::class)
->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id'])
->withPivot('role')
->get();
$ownedCompanies = $user->ownedCompanies()
->withoutGlobalScopes()
->withoutGlobalScope(PortalScope::class)
->whereNotIn('id', $linkedCompanies->pluck('id'))
->get(['id', 'name', 'portal', 'owner_user_id']);
@ -152,7 +153,7 @@ class CustomerCompanyContext
public function accessibleCompanyQuery(User $user): Builder
{
return Company::query()
->withoutGlobalScopes()
->withoutGlobalScope(PortalScope::class)
->accessibleBy($user);
}

View file

@ -82,7 +82,12 @@ class ImageService
$relativePath = $directory.'/'.$filename;
$disk = $this->disk();
$disk->put($relativePath, $upload->get(), 'public');
// Disks laufen mit throw=false: Ein Fehlschlag kommt nur als false
// zurück — ohne Prüfung bekäme der Aufrufer einen Pfad ohne Datei (F23).
if (! $disk->put($relativePath, $upload->get(), 'public')) {
throw new RuntimeException('Das Logo konnte nicht gespeichert werden.');
}
$variants = $this->generateLogoVariants($disk, $relativePath, $extension);
@ -127,7 +132,10 @@ class ImageService
$relativePath = $directory.'/'.$filename;
$disk = $this->disk($diskName);
$disk->put($relativePath, $upload->get(), $diskName === 'public' ? 'public' : 'private');
if (! $disk->put($relativePath, $upload->get(), $diskName === 'public' ? 'public' : 'private')) {
throw new RuntimeException('Das Bild konnte nicht gespeichert werden.');
}
$variants = $this->generateVariants(
$disk,

View file

@ -64,13 +64,21 @@ class PressReleaseAttachmentStorage
}
try {
$disk->put($relativePath, $stream, $diskName === 'public' ? 'public' : 'private');
$written = $disk->put($relativePath, $stream, $diskName === 'public' ? 'public' : 'private');
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
// Die Disks sind mit throw=false konfiguriert: Ein fehlgeschlagener
// Schreibvorgang (voller Datenträger, Rechte) kommt nur als false
// zurück. Ohne Prüfung entstünde ein Datensatz, dessen Datei nie
// existiert hat (F23).
if (! $written) {
throw new RuntimeException('Der Anhang konnte nicht gespeichert werden.');
}
return [
'disk' => $diskName,
'path' => $relativePath,

View file

@ -122,11 +122,11 @@ class PressReleaseService
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing
// anhand des Ergebnisses übernimmt der Job über
// routeByClassification().
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
ClassifyPressRelease::dispatch($pressRelease->id, contentHash: $pressRelease->content_hash)->onQueue('classification');
}
// Content-Score parallel berechnen (Qualität, ohne Statuswirkung).
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
ScorePressRelease::dispatch($pressRelease->id, contentHash: $pressRelease->content_hash)->onQueue('classification');
}
/**
@ -144,7 +144,7 @@ class PressReleaseService
return;
}
ClassifyPressRelease::dispatch($pressRelease->id, route: false)->onQueue('classification');
ClassifyPressRelease::dispatch($pressRelease->id, route: false, contentHash: $pressRelease->content_hash)->onQueue('classification');
}
/**
@ -157,7 +157,7 @@ class PressReleaseService
return;
}
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
ScorePressRelease::dispatch($pressRelease->id, contentHash: $pressRelease->content_hash)->onQueue('classification');
}
/**
@ -362,8 +362,16 @@ class PressReleaseService
$plan = $user->currentPlan();
if ($plan && (int) $user->press_release_quota_used_this_month < $plan->press_release_quota) {
$user->increment('press_release_quota_used_this_month');
if ($plan && $user->pressReleaseQuotaUsedThisMonth() < $plan->press_release_quota) {
// Periodenwechsel atomar unter der Zeilensperre: neuer Monat →
// Zähler beginnt bei 1, sonst erhöhen (Audit F18).
$period = User::currentQuotaPeriod();
$user->forceFill([
'press_release_quota_used_this_month' => $user->press_release_quota_period === $period
? (int) $user->press_release_quota_used_this_month + 1
: 1,
'press_release_quota_period' => $period,
])->save();
return;
}
@ -765,13 +773,13 @@ class PressReleaseService
// werden. Ohne hinterlegten Namen bleibt $recipientName leer,
// das Template grüßt dann neutral.
$contact = $pressRelease->contacts()
->withoutGlobalScopes()
->withoutGlobalScope(PortalScope::class)
->whereNotNull('contacts.email')
->where('contacts.email', '!=', '')
->orderBy('contacts.id')
->first();
$company = Company::withoutGlobalScopes()->find($pressRelease->company_id);
$company = Company::withoutGlobalScope(PortalScope::class)->find($pressRelease->company_id);
$companyName = trim((string) $company?->name);
// Gesperrte Adressen (Bounce-Suppression, 1b.6) überspringen:

View file

@ -8,6 +8,7 @@ use App\Enums\PressReleaseStatus;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use App\Scopes\PortalScope;
use App\Support\CrawlerIdentifier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@ -46,7 +47,7 @@ class VisibilityStats
*/
public function companySummary(Company $company, int $days = 30): array
{
$base = PressRelease::query()->withoutGlobalScopes()->where('company_id', $company->id);
$base = PressRelease::query()->withoutGlobalScope(PortalScope::class)->where('company_id', $company->id);
$row = $base->clone()
->selectRaw('count(*) as releases')

View file

@ -34,14 +34,24 @@ class NewsroomDetailPageData
// (Cache-Poisoning über z. B. ?page=2.5).
$page = Paginator::resolveCurrentPage();
// Bewusst VOR dem Seiten-Cache: eine Entfernung (410) muss sofort
// greifen, auch wenn für den Slug noch ein bis zu eine Stunde alter
// Stale-Eintrag im Cache liegt. Kostet einen indizierten Slug-Lookup
// pro Aufruf — vertretbar, weil Entfernungen rechtlich motiviert sind.
if ($this->isGone($context, $slug)) {
// Bewusst VOR dem Seiten-Cache: eine Entfernung (410), Löschung oder
// Deaktivierung muss sofort greifen, auch wenn für den Slug noch ein
// bis zu eine Stunde alter Stale-Eintrag im Cache liegt. Kostet einen
// indizierten Slug-Lookup pro Aufruf — vertretbar, weil Entfernungen
// rechtlich motiviert sind.
$visibility = $this->publicVisibility($context, $slug);
if ($visibility?->gone_at !== null) {
return ['tombstone' => 'removed'];
}
// Gelöscht (Soft Delete) oder deaktiviert: nie aus dem Cache bedienen.
// Der Neuaufbau liefert 404 — oder die Weiterleitung, falls die Firma
// in einer Zusammenführung aufgegangen ist (F13).
if ($visibility === null || ! $visibility->is_active) {
return $this->buildUncached($context, $slug);
}
$cacheKey = WebPageCache::key('newsroom', [
implode('-', $context->portalValues),
$context->language,
@ -57,17 +67,18 @@ class NewsroomDetailPageData
}
/**
* Newsroom mit 410 entfernt? Vollständig gelöschte Firmen (Soft Delete)
* bleiben bewusst außen vor — deren URL antwortet weiterhin mit 404.
* Sichtbarkeitsstatus der Firma hinter dem Slug (nur die dafür nötigen
* Spalten). Null bei unbekanntem Slug und bei vollständig gelöschten
* Firmen (Soft Delete) — deren URL antwortet weiterhin mit 404, ein
* gesetztes `gone_at` dagegen mit 410.
*/
private function isGone(WebPortalContext $context, string $slug): bool
private function publicVisibility(WebPortalContext $context, string $slug): ?Company
{
return Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $context->portalValues)
->where('slug', $slug)
->whereNotNull('gone_at')
->exists();
->first(['id', 'is_active', 'gone_at']);
}
/**

View file

@ -74,7 +74,7 @@ class ReleaseDetailPageData
// Altsystem), statt den Block ganz wegzulassen.
if ($release->contacts->isEmpty() && $release->company_id) {
$companyContact = Contact::query()
->withoutGlobalScopes()
->withoutGlobalScope(PortalScope::class)
->where('company_id', $release->company_id)
->orderBy('id')
->first();

View file

@ -0,0 +1,92 @@
<?php
namespace App\Services\Web;
use App\Enums\Portal;
use App\Http\Middleware\SetEdition;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
/**
* Ordnet eine gemeldete URL (Meldeverfahren) der Pressemitteilung zu, die
* unter dieser Adresse tatsächlich ausgeliefert wird.
*
* Der Slug allein reicht nicht: Der Unique-Index der Meldungen ist
* (portal, language, slug) — derselbe Slug kann auf beiden Portalen und in
* beiden Ausgaben existieren. Deshalb Host → Portal, erstes Pfadsegment →
* Ausgabe, dann erst der Slug. Fremde Hosts ergeben bewusst keinen Treffer;
* der Hub ebenfalls, er leitet Meldungs-URLs nur auf das Portal um (F12).
*/
class ReportedUrlResolver
{
public function resolve(string $url): ?PressRelease
{
$parts = parse_url(trim($url));
if ($parts === false || ! isset($parts['host'])) {
return null;
}
$portal = $this->portalForHost($parts['host']);
if ($portal === null) {
return null;
}
// Detail-Route: /{edition}/press-release/{slug}. Ohne Ausgabe im Pfad
// gilt wie in SetEdition die Standard-Ausgabe.
$editions = implode('|', SetEdition::EDITIONS);
if (! preg_match('#^/(?:('.$editions.')/)?press-release/([^/]+)/?$#', $parts['path'] ?? '', $matches)) {
return null;
}
$language = $matches[1] !== '' ? $matches[1] : SetEdition::DEFAULT_EDITION;
$slug = rawurldecode($matches[2]);
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues([$portal->value, Portal::Both->value])
->forLanguage($language)
->where('slug', $slug)
->visibleInArchive()
// Doppelveröffentlichung: beide Fassungen tragen denselben Slug —
// die Erstquelle gewinnt (wie in RedirectPortalOnlyPagesFromHub).
->orderByRaw('canonical_press_release_id is null desc')
->orderBy('id')
->first();
}
/**
* Portal hinter dem Host laut config/domains.php — oder null für jeden
* Host, der dort nicht als Portal steht (Hub, fremde Domains, Subdomains
* mit vorangestelltem Portalnamen).
*/
private function portalForHost(string $host): ?Portal
{
$host = strtolower($host);
if (str_starts_with($host, 'www.')) {
$host = substr($host, 4);
}
foreach ((array) config('domains.domains', []) as $key => $domain) {
$portal = Portal::tryFrom((string) $key);
if ($portal === null || $portal === Portal::Both) {
continue;
}
$configuredHosts = array_filter([
strtolower((string) ($domain['domain_name'] ?? '')),
strtolower((string) (parse_url((string) ($domain['url'] ?? ''), PHP_URL_HOST) ?: '')),
]);
if (in_array($host, $configuredHosts, true)) {
return $portal;
}
}
return null;
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Support;
use Illuminate\Contracts\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
/**
* Case-insensitiver E-Mail-Vergleich, der den B-Tree-Index nutzt (Audit F22).
*
* `LOWER(email) = ?` schließt jeden Index aus – auf `companies` (110.000
* Zeilen) ein Vollscan bei jedem Login. Die Spalten `users.email`,
* `companies.email` und `contacts.email` tragen die Kollation
* `utf8mb4_unicode_ci`, MySQL vergleicht also ohnehin ohne Groß-/Klein-
* schreibung; der direkte Vergleich reicht und trifft den Index.
*
* SQLite (Testsuite) vergleicht `=` binär – dort bleibt LOWER(), damit die
* Tests dieselbe Semantik prüfen. Der Aufrufer übergibt die Adresse bereits
* kleingeschrieben und getrimmt.
*/
final class EmailQuery
{
/**
* @template TBuilder of Builder|QueryBuilder
*
* @param TBuilder $query
* @return TBuilder
*/
public static function whereEmail(Builder|QueryBuilder $query, string $email, string $column = 'email'): Builder|QueryBuilder
{
$connection = $query->getConnection();
$driver = $connection instanceof Connection ? $connection->getDriverName() : 'unknown';
if ($driver === 'mysql' || $driver === 'mariadb') {
return $query->where($column, $email);
}
return $query->whereRaw('LOWER('.$column.') = ?', [$email]);
}
}