diff --git a/app/Console/Commands/AggregatePressReleaseViews.php b/app/Console/Commands/AggregatePressReleaseViews.php index 3390899b..ba2b3a6f 100644 --- a/app/Console/Commands/AggregatePressReleaseViews.php +++ b/app/Console/Commands/AggregatePressReleaseViews.php @@ -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); diff --git a/app/Console/Commands/ImportNewsletterBounces.php b/app/Console/Commands/ImportNewsletterBounces.php index b58946e1..d7f4cbe1 100644 --- a/app/Console/Commands/ImportNewsletterBounces.php +++ b/app/Console/Commands/ImportNewsletterBounces.php @@ -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) { diff --git a/app/Console/Commands/PublishScheduledPressReleases.php b/app/Console/Commands/PublishScheduledPressReleases.php index e227bb3c..cdb30822 100644 --- a/app/Console/Commands/PublishScheduledPressReleases.php +++ b/app/Console/Commands/PublishScheduledPressReleases.php @@ -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; } } diff --git a/app/Console/Commands/ResetMonthlyPressReleaseQuota.php b/app/Console/Commands/ResetMonthlyPressReleaseQuota.php index 227429ae..b77f72da 100644 --- a/app/Console/Commands/ResetMonthlyPressReleaseQuota.php +++ b/app/Console/Commands/ResetMonthlyPressReleaseQuota.php @@ -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)); diff --git a/app/Http/Controllers/Api/Legacy/LegacyContactController.php b/app/Http/Controllers/Api/Legacy/LegacyContactController.php index cbe69299..dfe32901 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyContactController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyContactController.php @@ -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(); diff --git a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php index 19a8c2b2..45299818 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php @@ -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.']); diff --git a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php index 60742c63..baac1074 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php @@ -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'))]); diff --git a/app/Http/Controllers/Api/V1/CompanyController.php b/app/Http/Controllers/Api/V1/CompanyController.php index a36e215e..d0f8dca6 100644 --- a/app/Http/Controllers/Api/V1/CompanyController.php +++ b/app/Http/Controllers/Api/V1/CompanyController.php @@ -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(); diff --git a/app/Http/Controllers/Api/V1/PressReleaseController.php b/app/Http/Controllers/Api/V1/PressReleaseController.php index 8aab5be3..5277d511 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseController.php @@ -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(); diff --git a/app/Http/Controllers/Api/V1/PressReleaseImageController.php b/app/Http/Controllers/Api/V1/PressReleaseImageController.php index 51e3f22f..dfb910fd 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseImageController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseImageController.php @@ -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(); diff --git a/app/Http/Controllers/Auth/MagicLinkConsumeController.php b/app/Http/Controllers/Auth/MagicLinkConsumeController.php index 698f4160..03833be8 100644 --- a/app/Http/Controllers/Auth/MagicLinkConsumeController.php +++ b/app/Http/Controllers/Auth/MagicLinkConsumeController.php @@ -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); diff --git a/app/Http/Controllers/PressReleasePreviewController.php b/app/Http/Controllers/PressReleasePreviewController.php index ce3995e2..129050ea 100644 --- a/app/Http/Controllers/PressReleasePreviewController.php +++ b/app/Http/Controllers/PressReleasePreviewController.php @@ -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', diff --git a/app/Http/Controllers/ProofPdfController.php b/app/Http/Controllers/ProofPdfController.php index 33ec1a71..6d1f6cb8 100644 --- a/app/Http/Controllers/ProofPdfController.php +++ b/app/Http/Controllers/ProofPdfController.php @@ -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); diff --git a/app/Http/Controllers/Web/NewsroomClaimController.php b/app/Http/Controllers/Web/NewsroomClaimController.php index adae4105..61b8f755 100644 --- a/app/Http/Controllers/Web/NewsroomClaimController.php +++ b/app/Http/Controllers/Web/NewsroomClaimController.php @@ -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 diff --git a/app/Http/Controllers/Web/PressReleaseAttachmentMediaController.php b/app/Http/Controllers/Web/PressReleaseAttachmentMediaController.php index 5b86215c..ac318de5 100644 --- a/app/Http/Controllers/Web/PressReleaseAttachmentMediaController.php +++ b/app/Http/Controllers/Web/PressReleaseAttachmentMediaController.php @@ -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; diff --git a/app/Http/Controllers/Web/PressReleaseImageMediaController.php b/app/Http/Controllers/Web/PressReleaseImageMediaController.php index aa51cc5e..537385ce 100644 --- a/app/Http/Controllers/Web/PressReleaseImageMediaController.php +++ b/app/Http/Controllers/Web/PressReleaseImageMediaController.php @@ -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; diff --git a/app/Http/Controllers/Web/PressReleaseReportController.php b/app/Http/Controllers/Web/PressReleaseReportController.php index 95b49558..f10b8566 100644 --- a/app/Http/Controllers/Web/PressReleaseReportController.php +++ b/app/Http/Controllers/Web/PressReleaseReportController.php @@ -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 */ 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. diff --git a/app/Http/Controllers/Web/ReportFormController.php b/app/Http/Controllers/Web/ReportFormController.php index e49956b7..01308651 100644 --- a/app/Http/Controllers/Web/ReportFormController.php +++ b/app/Http/Controllers/Web/ReportFormController.php @@ -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(); diff --git a/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php b/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php index 8dd01f56..326133d1 100644 --- a/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php +++ b/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php @@ -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)); } diff --git a/app/Http/Requests/Api/V1/StorePressReleaseRequest.php b/app/Http/Requests/Api/V1/StorePressReleaseRequest.php index 169c14c4..acf92164 100644 --- a/app/Http/Requests/Api/V1/StorePressReleaseRequest.php +++ b/app/Http/Requests/Api/V1/StorePressReleaseRequest.php @@ -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']); + } } diff --git a/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php b/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php index 6b1f4658..71b5b9ee 100644 --- a/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php +++ b/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php @@ -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']); + } } diff --git a/app/Jobs/ClassifyPressRelease.php b/app/Jobs/ClassifyPressRelease.php index 64719e38..4af5f42a 100644 --- a/app/Jobs/ClassifyPressRelease.php +++ b/app/Jobs/ClassifyPressRelease.php @@ -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(), ]); diff --git a/app/Jobs/ScorePressRelease.php b/app/Jobs/ScorePressRelease.php index 4362cce8..b3004675 100644 --- a/app/Jobs/ScorePressRelease.php +++ b/app/Jobs/ScorePressRelease.php @@ -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(), ]); } diff --git a/app/Models/Contact.php b/app/Models/Contact.php index e1f385cc..b07d7063 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -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'); diff --git a/app/Models/KiAudit.php b/app/Models/KiAudit.php index ccf5755e..66f717f0 100644 --- a/app/Models/KiAudit.php +++ b/app/Models/KiAudit.php @@ -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', ]; } diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 1583055d..332f17b0 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -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, diff --git a/app/Models/User.php b/app/Models/User.php index d721f89c..14ff463e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 diff --git a/app/Observers/CompanyPublicCacheObserver.php b/app/Observers/CompanyPublicCacheObserver.php new file mode 100644 index 00000000..029c09eb --- /dev/null +++ b/app/Observers/CompanyPublicCacheObserver.php @@ -0,0 +1,50 @@ +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(); + } +} diff --git a/app/Policies/CompanyPolicy.php b/app/Policies/CompanyPolicy.php index 5e0b4f99..9efb60f1 100644 --- a/app/Policies/CompanyPolicy.php +++ b/app/Policies/CompanyPolicy.php @@ -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(); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cb5e5a03..75625dc5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index 2761ebbf..2a0e5a36 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -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 */ 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(); } diff --git a/app/Services/Customer/CustomerCompanyContext.php b/app/Services/Customer/CustomerCompanyContext.php index ddee96b7..8279166b 100644 --- a/app/Services/Customer/CustomerCompanyContext.php +++ b/app/Services/Customer/CustomerCompanyContext.php @@ -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); } diff --git a/app/Services/Image/ImageService.php b/app/Services/Image/ImageService.php index bdb0b4e8..d0ac1c8a 100644 --- a/app/Services/Image/ImageService.php +++ b/app/Services/Image/ImageService.php @@ -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, diff --git a/app/Services/PressRelease/PressReleaseAttachmentStorage.php b/app/Services/PressRelease/PressReleaseAttachmentStorage.php index 63d90275..e284a13e 100644 --- a/app/Services/PressRelease/PressReleaseAttachmentStorage.php +++ b/app/Services/PressRelease/PressReleaseAttachmentStorage.php @@ -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, diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index 114b8c56..c4f7a3d8 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -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: diff --git a/app/Services/PressRelease/VisibilityStats.php b/app/Services/PressRelease/VisibilityStats.php index 800487df..839e3718 100644 --- a/app/Services/PressRelease/VisibilityStats.php +++ b/app/Services/PressRelease/VisibilityStats.php @@ -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') diff --git a/app/Services/Web/NewsroomDetailPageData.php b/app/Services/Web/NewsroomDetailPageData.php index 673c5505..19c2564f 100644 --- a/app/Services/Web/NewsroomDetailPageData.php +++ b/app/Services/Web/NewsroomDetailPageData.php @@ -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']); } /** diff --git a/app/Services/Web/ReleaseDetailPageData.php b/app/Services/Web/ReleaseDetailPageData.php index 0f355da9..77563d39 100644 --- a/app/Services/Web/ReleaseDetailPageData.php +++ b/app/Services/Web/ReleaseDetailPageData.php @@ -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(); diff --git a/app/Services/Web/ReportedUrlResolver.php b/app/Services/Web/ReportedUrlResolver.php new file mode 100644 index 00000000..5cc28272 --- /dev/null +++ b/app/Services/Web/ReportedUrlResolver.php @@ -0,0 +1,92 @@ +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; + } +} diff --git a/app/Support/EmailQuery.php b/app/Support/EmailQuery.php new file mode 100644 index 00000000..fa6de2d3 --- /dev/null +++ b/app/Support/EmailQuery.php @@ -0,0 +1,41 @@ +getConnection(); + $driver = $connection instanceof Connection ? $connection->getDriverName() : 'unknown'; + + if ($driver === 'mysql' || $driver === 'mariadb') { + return $query->where($column, $email); + } + + return $query->whereRaw('LOWER('.$column.') = ?', [$email]); + } +} diff --git a/config/queue.php b/config/queue.php index 116bd8d0..7d0d81ff 100644 --- a/config/queue.php +++ b/config/queue.php @@ -39,7 +39,7 @@ return [ 'connection' => env('DB_QUEUE_CONNECTION'), 'table' => env('DB_QUEUE_TABLE', 'jobs'), 'queue' => env('DB_QUEUE', 'default'), - 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 150), 'after_commit' => false, ], @@ -67,7 +67,7 @@ return [ 'driver' => 'redis', 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 150), 'block_for' => null, 'after_commit' => false, ], diff --git a/database/migrations/2026_09_18_160000_add_email_index_to_contacts_table.php b/database/migrations/2026_09_18_160000_add_email_index_to_contacts_table.php new file mode 100644 index 00000000..f4791121 --- /dev/null +++ b/database/migrations/2026_09_18_160000_add_email_index_to_contacts_table.php @@ -0,0 +1,31 @@ +index('email', 'contacts_email_idx'); + }); + } + + public function down(): void + { + Schema::table('contacts', function (Blueprint $table) { + $table->dropIndex('contacts_email_idx'); + }); + } +}; diff --git a/database/migrations/2026_09_18_170000_add_content_hash_to_ki_audits_table.php b/database/migrations/2026_09_18_170000_add_content_hash_to_ki_audits_table.php new file mode 100644 index 00000000..b2c9c2bd --- /dev/null +++ b/database/migrations/2026_09_18_170000_add_content_hash_to_ki_audits_table.php @@ -0,0 +1,40 @@ +char('content_hash', 64)->nullable()->after('raw_response'); + } + + if (! Schema::hasColumn('ki_audits', 'discarded_at')) { + $table->timestamp('discarded_at')->nullable()->after('content_hash'); + } + }); + } + + public function down(): void + { + Schema::table('ki_audits', function (Blueprint $table) { + $table->dropColumn(array_filter([ + Schema::hasColumn('ki_audits', 'content_hash') ? 'content_hash' : null, + Schema::hasColumn('ki_audits', 'discarded_at') ? 'discarded_at' : null, + ])); + }); + } +}; diff --git a/database/migrations/2026_09_18_170100_add_quota_period_to_users_table.php b/database/migrations/2026_09_18_170100_add_quota_period_to_users_table.php new file mode 100644 index 00000000..2a4ff12a --- /dev/null +++ b/database/migrations/2026_09_18_170100_add_quota_period_to_users_table.php @@ -0,0 +1,39 @@ +char('press_release_quota_period', 7)->nullable()->after('press_release_quota_used_this_month'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + if (Schema::hasColumn('users', 'press_release_quota_period')) { + $table->dropColumn('press_release_quota_period'); + } + }); + } +}; diff --git a/dev/betrieb/deploy-runbook.md b/dev/betrieb/deploy-runbook.md index f5950a9c..8210bfc2 100644 --- a/dev/betrieb/deploy-runbook.md +++ b/dev/betrieb/deploy-runbook.md @@ -152,6 +152,14 @@ ExecStart=/usr/bin/php /var/www/pressekonto/artisan queue:work database \ WantedBy=multi-user.target ``` +Zeitbudgets (Audit F17, 18.09.2026): Die KI-Jobs (`ClassifyPressRelease`, +`ScorePressRelease`) tragen `$timeout = 120`, weil drei HTTP-Versuche à 30 s +die 60 s des Worker-Defaults sprengen. `retry_after` der Datenbank-Queue +steht deshalb auf 150 (`config/queue.php`, überschreibbar per +`DB_QUEUE_RETRY_AFTER`). Wer einen zweiten Worker startet, hält +`retry_after` über dem größten Job-Timeout — sonst greift ein Worker den +noch laufenden Job des anderen. + ## 5. Scheduler / Cron (Pflicht) Crontab des Web-Users: diff --git a/resources/views/livewire/admin/companies/edit.blade.php b/resources/views/livewire/admin/companies/edit.blade.php index eabcb4ed..d574a2f4 100644 --- a/resources/views/livewire/admin/companies/edit.blade.php +++ b/resources/views/livewire/admin/companies/edit.blade.php @@ -145,41 +145,68 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends } $slug = $company->generateUniqueSlug($this->company_name, ['portal' => $this->portal]); - $logoPath = $company->logo_path; - $logoVariants = $company->logo_variants; + $previousLogoPath = $company->logo_path; + $previousLogoVariants = $company->logo_variants; + $logoPath = $previousLogoPath; + $logoVariants = $previousLogoVariants; + $discardPreviousLogo = false; + $stored = null; if ($this->remove_logo) { - $imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants); $logoPath = null; $logoVariants = null; + $discardPreviousLogo = true; } if ($this->logo) { - $imageService->deleteCompanyLogo($logoPath, $logoVariants); + // Erst die neue Datei speichern, dann die DB-Referenz wechseln, das + // alte Logo erst danach löschen: Scheitert der Storage, bleiben + // bisheriges Logo und Datensatz unangetastet (F23). + try { + $stored = $imageService->storeCompanyLogo( + $this->logo, + $this->portal === Portal::Both->value ? Portal::Presseecho->value : $this->portal, + $company->id, + ); + } catch (\RuntimeException $exception) { + report($exception); - $stored = $imageService->storeCompanyLogo( - $this->logo, - $this->portal === Portal::Both->value ? Portal::Presseecho->value : $this->portal, - $company->id, - ); + throw ValidationException::withMessages([ + 'logo' => __('Das Logo konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.'), + ]); + } $logoPath = $stored['path']; $logoVariants = $stored['variants']; + $discardPreviousLogo = true; } - $company->update([ - 'portal' => $this->portal, - 'name' => $this->company_name, - 'slug' => $slug, - 'address' => $this->composeAddress(), - 'country_code' => strtoupper($this->country), - 'phone' => $this->phone ?: null, - 'email' => $this->email ?: null, - 'website' => $this->website ?: null, - 'logo_path' => $logoPath, - 'logo_variants' => $logoVariants, - 'is_active' => $this->is_active, - ]); + try { + $company->update([ + 'portal' => $this->portal, + 'name' => $this->company_name, + 'slug' => $slug, + 'address' => $this->composeAddress(), + 'country_code' => strtoupper($this->country), + 'phone' => $this->phone ?: null, + 'email' => $this->email ?: null, + 'website' => $this->website ?: null, + 'logo_path' => $logoPath, + 'logo_variants' => $logoVariants, + 'is_active' => $this->is_active, + ]); + } catch (\Throwable $exception) { + // Ohne DB-Referenz wäre die neue Datei verwaist. + if ($stored !== null) { + $imageService->deleteCompanyLogo($stored['path'], $stored['variants']); + } + + throw $exception; + } + + if ($discardPreviousLogo && $previousLogoPath !== null && $previousLogoPath !== $logoPath) { + $imageService->deleteCompanyLogo($previousLogoPath, $previousLogoVariants); + } session()->flash('success', 'Firma erfolgreich aktualisiert.'); $this->redirect(route('admin.companies.index'), navigate: true); diff --git a/resources/views/livewire/components/press-release-attachments-manager.blade.php b/resources/views/livewire/components/press-release-attachments-manager.blade.php index 66dac565..441d9802 100644 --- a/resources/views/livewire/components/press-release-attachments-manager.blade.php +++ b/resources/views/livewire/components/press-release-attachments-manager.blade.php @@ -4,6 +4,7 @@ use App\Enums\PressReleaseStatus; use App\Models\PressRelease; use App\Models\PressReleaseAttachment; use App\Services\PressRelease\PressReleaseAttachmentStorage; +use App\Scopes\PortalScope; use Flux\Flux; use Livewire\Attributes\Locked; use Livewire\Volt\Component; @@ -213,7 +214,7 @@ new class extends Component private function getPressRelease(): PressRelease { - return PressRelease::withoutGlobalScopes() + return PressRelease::withoutGlobalScope(PortalScope::class) ->findOrFail($this->pressReleaseId); } diff --git a/resources/views/livewire/components/press-release-images-manager.blade.php b/resources/views/livewire/components/press-release-images-manager.blade.php index 812b74a2..1665fa98 100644 --- a/resources/views/livewire/components/press-release-images-manager.blade.php +++ b/resources/views/livewire/components/press-release-images-manager.blade.php @@ -6,6 +6,7 @@ use App\Enums\PressReleaseStatus; use App\Models\PressRelease; use App\Models\PressReleaseImage; use App\Services\Image\ImageService; +use App\Scopes\PortalScope; use Flux\Flux; use Illuminate\Validation\Rule; use Livewire\Attributes\Locked; @@ -330,7 +331,7 @@ new class extends Component { private function getPressRelease(): PressRelease { - return PressRelease::withoutGlobalScopes()->findOrFail($this->pressReleaseId); + return PressRelease::withoutGlobalScope(PortalScope::class)->findOrFail($this->pressReleaseId); } private function canChangeImages(PressRelease $pressRelease): bool diff --git a/resources/views/livewire/customer/dashboard.blade.php b/resources/views/livewire/customer/dashboard.blade.php index 0c7dc76f..143a1374 100644 --- a/resources/views/livewire/customer/dashboard.blade.php +++ b/resources/views/livewire/customer/dashboard.blade.php @@ -8,6 +8,7 @@ use App\Models\PressRelease; use App\Models\Profile; use App\Models\User; use App\Services\Customer\CustomerCompanyContext; +use App\Scopes\PortalScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Livewire\Attributes\Layout; @@ -22,7 +23,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C $selectedCompanyId = $context->selectedCompanyId($user); $selectedCompany = $selectedCompanyId === null ? null : $context->findFor($user, $selectedCompanyId); - $pressReleaseQuery = PressRelease::withoutGlobalScopes() + $pressReleaseQuery = PressRelease::withoutGlobalScope(PortalScope::class) ->where('user_id', $user->id) ->when($selectedCompanyId !== null, fn($query) => $query->where('company_id', $selectedCompanyId)); diff --git a/resources/views/livewire/customer/press-kits/index.blade.php b/resources/views/livewire/customer/press-kits/index.blade.php index d6ed4490..fcc2249a 100644 --- a/resources/views/livewire/customer/press-kits/index.blade.php +++ b/resources/views/livewire/customer/press-kits/index.blade.php @@ -3,6 +3,7 @@ use App\Models\Company; use App\Models\User; use App\Services\Customer\CustomerCompanyContext; +use App\Scopes\PortalScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -189,12 +190,12 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com $companyIdsQuery = $this->baseQuery($user)->select('companies.id'); $pressReleaseCount = (int) \App\Models\PressRelease::query() - ->withoutGlobalScopes() + ->withoutGlobalScope(PortalScope::class) ->whereIn('company_id', $companyIdsQuery) ->count(); $contactsCount = (int) \App\Models\Contact::query() - ->withoutGlobalScopes() + ->withoutGlobalScope(PortalScope::class) ->whereIn('company_id', $companyIdsQuery) ->count(); @@ -338,10 +339,10 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com ->limit(1), ]) ->withCount([ - 'contacts' => fn ($q) => $q->withoutGlobalScopes(), - 'pressReleases' => fn ($q) => $q->withoutGlobalScopes(), + 'contacts' => fn ($q) => $q->withoutGlobalScope(PortalScope::class), + 'pressReleases' => fn ($q) => $q->withoutGlobalScope(PortalScope::class), ]) - ->withMax(['pressReleases' => fn ($q) => $q->withoutGlobalScopes()], 'published_at'); + ->withMax(['pressReleases' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)], 'published_at'); $this->applySavedView($query, $user, $this->savedView); $this->applySharedFilters($query); diff --git a/resources/views/livewire/customer/press-kits/show.blade.php b/resources/views/livewire/customer/press-kits/show.blade.php index 65518a4b..4a21c186 100644 --- a/resources/views/livewire/customer/press-kits/show.blade.php +++ b/resources/views/livewire/customer/press-kits/show.blade.php @@ -3,6 +3,7 @@ use App\Models\Company; use App\Models\Contact; use App\Models\PressRelease; +use App\Scopes\PortalScope; use App\Rules\NoPromotionalText; use App\Services\Company\CompanyProfileCheck; use App\Services\Conversion\ConversionRecorder; @@ -144,22 +145,51 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component 'disable_footer_code' => $this->companyDisableFooterCode, ]); + $previousLogoPath = $company->logo_path; + $previousLogoVariants = $company->logo_variants; + $discardPreviousLogo = false; + $stored = null; + if ($this->removeCompanyLogo) { - $imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants); $company->logo_path = null; $company->logo_variants = null; + $discardPreviousLogo = true; } if ($this->companyLogo) { - $imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants); + // Erst die neue Datei speichern, dann die DB-Referenz wechseln, das + // alte Logo erst danach löschen: Scheitert der Storage, bleiben + // bisheriges Logo und Datensatz unangetastet (F23). + try { + $stored = $imageService->storeCompanyLogo($this->companyLogo, $company->portal?->value ?? 'presseecho', $company->id); + } catch (\RuntimeException $exception) { + report($exception); - $stored = $imageService->storeCompanyLogo($this->companyLogo, $company->portal?->value ?? 'presseecho', $company->id); + throw ValidationException::withMessages([ + 'companyLogo' => __('Das Logo konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.'), + ]); + } $company->logo_path = $stored['path']; $company->logo_variants = $stored['variants']; + $discardPreviousLogo = true; + } + + try { + $company->save(); + } catch (\Throwable $exception) { + // Ohne DB-Referenz wäre die neue Datei verwaist. + if ($stored !== null) { + $imageService->deleteCompanyLogo($stored['path'], $stored['variants']); + } + + throw $exception; + } + + if ($discardPreviousLogo && $previousLogoPath !== null && $previousLogoPath !== $company->logo_path) { + $imageService->deleteCompanyLogo($previousLogoPath, $previousLogoVariants); } - $company->save(); $this->resetCompanyForm(); // Stufe 3 der Konversionsleiter: Newsroom gepflegt (einmal je Firma). @@ -237,6 +267,10 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component session()->flash('contact-status', __('Pressekontakt wurde angelegt.')); } + // Die Kontakte liegen mit im gecachten Newsroom-Seitenpaket — die + // Änderung soll nicht erst nach Ablauf des Seiten-Caches greifen. + WebCaches::forgetPublicPages(); + $this->resetContactForm(); } @@ -247,6 +281,8 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component $contact = $this->contact($contactId); $contact->delete(); + WebCaches::forgetPublicPages(); + if ($this->editingContactId === $contactId) { $this->resetContactForm(); } @@ -270,7 +306,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component 'canManageCompany' => $user->can('update', $company), 'canManageContacts' => $user->can('update', $company), 'countries' => (array) config('countries.items', []), - 'contacts' => Contact::withoutGlobalScopes() + 'contacts' => Contact::withoutGlobalScope(PortalScope::class) ->where('company_id', $company->id) ->withCount('pressReleases') ->orderBy('last_name') @@ -282,7 +318,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component // für ihn eingeliefert hat — die Policy erlaubt das Firmen- // mitgliedern ohnehin. Sortiert nach Veröffentlichung, Entwürfe // nach Anlage. - 'pressReleases' => $pressReleases = PressRelease::withoutGlobalScopes() + 'pressReleases' => $pressReleases = PressRelease::withoutGlobalScope(PortalScope::class) ->where('company_id', $company->id) ->orderByRaw('coalesce(published_at, created_at) desc') ->limit(15) @@ -303,7 +339,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component private function contact(int $contactId): Contact { - return Contact::withoutGlobalScopes()->where('company_id', $this->id)->findOrFail($contactId); + return Contact::withoutGlobalScope(PortalScope::class)->where('company_id', $this->id)->findOrFail($contactId); } private function resetCompanyForm(): void diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index 219c9b44..862edf02 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -15,6 +15,7 @@ use App\Services\PressRelease\PressReleaseCoverImage; use App\Services\PressRelease\PressReleaseHtmlSanitizer; use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\QuotaExceededException; +use App\Scopes\PortalScope; use Flux\Flux; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; @@ -112,7 +113,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $this->backlinkUrl = $pr->backlink_url ?? ''; $this->boilerplateOverride = $pr->boilerplate_override ?? ''; $this->useBoilerplateOverride = filled($pr->boilerplate_override); - $this->contactId = $pr->contacts()->withoutGlobalScopes()->first()?->id + $this->contactId = $pr->contacts()->withoutGlobalScope(PortalScope::class)->first()?->id ?? $this->defaultContactIdFor((int) $pr->company_id); if ($pr->scheduled_at) { diff --git a/resources/views/livewire/customer/press-releases/index.blade.php b/resources/views/livewire/customer/press-releases/index.blade.php index 1be26a91..cdc3195c 100644 --- a/resources/views/livewire/customer/press-releases/index.blade.php +++ b/resources/views/livewire/customer/press-releases/index.blade.php @@ -10,6 +10,7 @@ use App\Services\PressRelease\BlacklistViolationException; use App\Services\PressRelease\BookingRequiredException; use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\QuotaExceededException; +use App\Scopes\PortalScope; use Flux\Flux; use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; @@ -174,7 +175,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class private function findMyPR(int $id): ?PressRelease { - return PressRelease::withoutGlobalScopes() + return PressRelease::withoutGlobalScope(PortalScope::class) ->where('id', $id) ->where('user_id', auth()->id()) ->first(); diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index ae42ad38..04a76c76 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -22,6 +22,7 @@ use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\QuotaExceededException; use App\Services\PressRelease\VisibilityStats; use App\Services\SearchConsole\IndexStatusChecker; +use App\Scopes\PortalScope; use Flux\Flux; use Livewire\Attributes\Layout; use Livewire\Attributes\Locked; @@ -907,7 +908,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends ->withCount(['pressReleases', 'contacts']), 'category.translations', 'contacts' => fn ($query) => $query - ->withoutGlobalScopes() + ->withoutGlobalScope(PortalScope::class) ->orderBy('last_name') ->orderBy('first_name') ->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']), diff --git a/tests/Feature/Admin/CompanyLogoReplacementTest.php b/tests/Feature/Admin/CompanyLogoReplacementTest.php new file mode 100644 index 00000000..3a1cc27e --- /dev/null +++ b/tests/Feature/Admin/CompanyLogoReplacementTest.php @@ -0,0 +1,106 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +function failingPublicDisk(): FilesystemAdapter +{ + $disk = Storage::fake('public'); + + // Proxy auf den echten Fake: nur put() schlägt fehl, alles andere — + // exists(), delete(), Livewires Temp-Upload — läuft echt. + $failingDisk = Mockery::mock($disk); + $failingDisk->shouldReceive('put')->andReturn(false); + Storage::set('public', $failingDisk); + + return $disk; +} + +test('the image service refuses to report a logo that was not written', function () { + /** @var TestCase $this */ + failingPublicDisk(); + + expect(fn () => app(ImageService::class)->storeCompanyLogo(UploadedFile::fake()->image('logo.png', 400, 400), 'presseecho', 1)) + ->toThrow(RuntimeException::class, 'Das Logo konnte nicht gespeichert werden.'); +}); + +test('the image service refuses to report a press release image that was not written', function () { + /** @var TestCase $this */ + failingPublicDisk(); + + expect(fn () => app(ImageService::class)->storePressReleaseImage(UploadedFile::fake()->image('bild.jpg', 800, 600), 1, 'public')) + ->toThrow(RuntimeException::class, 'Das Bild konnte nicht gespeichert werden.'); +}); + +test('a failed logo write in the admin form keeps the previous logo and reports a validation error', function () { + /** @var TestCase $this */ + $disk = failingPublicDisk(); + $disk->put('company-logos/presseecho/9/alt.png', 'alt'); + $disk->put('company-logos/presseecho/9/alt-sq.png', 'alt-sq'); + + $company = Company::factory()->presseecho()->create([ + 'logo_path' => 'company-logos/presseecho/9/alt.png', + 'logo_variants' => ['sq' => 'company-logos/presseecho/9/alt-sq.png'], + ]); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + LivewireVolt::actingAs($admin) + ->test('admin.companies.edit', ['id' => $company->id]) + ->set('logo', UploadedFile::fake()->image('neu.png', 400, 400)) + ->call('update') + ->assertHasErrors(['logo']); + + $company->refresh(); + + expect($company->logo_path)->toBe('company-logos/presseecho/9/alt.png') + ->and($company->logo_variants)->toBe(['sq' => 'company-logos/presseecho/9/alt-sq.png']) + ->and($disk->exists('company-logos/presseecho/9/alt.png'))->toBeTrue() + ->and($disk->exists('company-logos/presseecho/9/alt-sq.png'))->toBeTrue(); +}); + +test('a successful logo replacement in the admin form removes the previous files only afterwards', function () { + /** @var TestCase $this */ + $disk = Storage::fake('public'); + $disk->put('company-logos/presseecho/9/alt.png', 'alt'); + $disk->put('company-logos/presseecho/9/alt-sq.png', 'alt-sq'); + + $company = Company::factory()->presseecho()->create([ + 'logo_path' => 'company-logos/presseecho/9/alt.png', + 'logo_variants' => ['sq' => 'company-logos/presseecho/9/alt-sq.png'], + ]); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + LivewireVolt::actingAs($admin) + ->test('admin.companies.edit', ['id' => $company->id]) + ->set('logo', UploadedFile::fake()->image('neu.png', 400, 400)) + ->call('update') + ->assertHasNoErrors(); + + $company->refresh(); + + expect($company->logo_path)->not->toBe('company-logos/presseecho/9/alt.png') + ->and($disk->exists((string) $company->logo_path))->toBeTrue() + ->and($disk->exists('company-logos/presseecho/9/alt.png'))->toBeFalse() + ->and($disk->exists('company-logos/presseecho/9/alt-sq.png'))->toBeFalse(); +}); diff --git a/tests/Feature/Api/V1/PressReleaseApiTest.php b/tests/Feature/Api/V1/PressReleaseApiTest.php index 54779f62..9c8e94ff 100644 --- a/tests/Feature/Api/V1/PressReleaseApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseApiTest.php @@ -1,5 +1,6 @@ getJson("/api/v1/press-releases/{$pressRelease->id}") ->assertForbidden(); }); + +test('a soft deleted press release disappears from the api (Audit F04)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $pressRelease = PressRelease::factory()->create(['user_id' => $user->id, 'title' => 'Bald geloescht']); + + Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + + $this->deleteJson("/api/v1/press-releases/{$pressRelease->id}")->assertNoContent(); + + $this->getJson('/api/v1/press-releases')->assertOk()->assertJsonMissing(['title' => 'Bald geloescht']); + $this->getJson("/api/v1/press-releases/{$pressRelease->id}")->assertForbidden(); + $this->patchJson("/api/v1/press-releases/{$pressRelease->id}", ['title' => 'Wiederbelebt'])->assertForbidden(); + + expect(PressRelease::withoutGlobalScopes()->withTrashed()->find($pressRelease->id)?->title)->toBe('Bald geloescht'); +}); + +test('a soft deleted company is neither listed nor usable for new press releases (Audit F04)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $company = Company::factory()->presseecho()->create(['name' => 'Geloeschte Firma']); + $category = Category::factory()->withTranslations()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + $company->delete(); + + Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']); + + $this->getJson('/api/v1/companies')->assertOk()->assertJsonMissing(['name' => 'Geloeschte Firma']); + $this->getJson("/api/v1/companies/{$company->id}")->assertForbidden(); + + $this->postJson('/api/v1/press-releases', [ + 'company_id' => $company->id, + 'category_id' => $category->id, + 'language' => 'de', + 'title' => 'Fuer eine geloeschte Firma', + 'text' => 'Inhalt', + ])->assertForbidden(); +}); + +test('api list parameters are validated instead of passed through (Audit F11)', function (array $query) { + /** @var TestCase $this */ + $user = User::factory()->create(); + PressRelease::factory()->count(3)->create(['user_id' => $user->id]); + + Sanctum::actingAs($user, ['press-releases:read', 'companies:read']); + + $this->getJson('/api/v1/press-releases?'.http_build_query($query))->assertUnprocessable(); + $this->getJson('/api/v1/companies?'.http_build_query(array_intersect_key($query, ['per_page' => true])) ?: '/api/v1/companies?per_page=-1') + ->assertUnprocessable(); +})->with([ + 'negative per_page' => [['per_page' => -1]], + 'zero per_page' => [['per_page' => 0]], + 'per_page above the cap' => [['per_page' => 101]], + 'textual per_page' => [['per_page' => 'alle']], + 'status as array' => [['status' => ['draft'], 'per_page' => -1]], + 'unknown status' => [['status' => 'gibt-es-nicht', 'per_page' => -1]], +]); + +test('api list accepts the documented parameter range and always limits the query (Audit F11)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + PressRelease::factory()->count(3)->create(['user_id' => $user->id]); + + Sanctum::actingAs($user, ['press-releases:read']); + + $this->getJson('/api/v1/press-releases?per_page=1')->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('meta.per_page', 1); + $this->getJson('/api/v1/press-releases?per_page=100&status=draft')->assertOk()->assertJsonCount(3, 'data'); +}); + +test('api press release input rejects unsupported languages and inactive or foreign categories (Audit F11)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $company = Company::factory()->presseecho()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + $category = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => true]); + $inactive = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => false]); + $foreign = Category::factory()->withTranslations()->create(['portal' => Portal::Businessportal24, 'is_active' => true]); + + Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + + $payload = fn (array $overrides): array => [ + 'company_id' => $company->id, + 'category_id' => $category->id, + 'language' => 'de', + 'title' => 'Vertragspruefung', + 'text' => 'Inhalt', + ...$overrides, + ]; + + $this->postJson('/api/v1/press-releases', $payload(['language' => 'zz']))->assertUnprocessable()->assertJsonValidationErrors(['language']); + $this->postJson('/api/v1/press-releases', $payload(['category_id' => $inactive->id]))->assertUnprocessable()->assertJsonValidationErrors(['category_id']); + $this->postJson('/api/v1/press-releases', $payload(['category_id' => $foreign->id]))->assertUnprocessable()->assertJsonValidationErrors(['category_id']); + $this->postJson('/api/v1/press-releases', $payload(['language' => 'en']))->assertCreated(); +}); diff --git a/tests/Feature/Auth/ContactAccessTest.php b/tests/Feature/Auth/ContactAccessTest.php index c3df38b9..bc07f31d 100644 --- a/tests/Feature/Auth/ContactAccessTest.php +++ b/tests/Feature/Auth/ContactAccessTest.php @@ -392,3 +392,14 @@ test('the admin form keeps the auto origin of untouched rows and marks changed r $user->syncCompaniesWithRoles([$auto->id], [$auto->id => 'owner']); expect($user->companies()->withoutGlobalScopes()->whereKey($auto->id)->first()->pivot->granted_via)->toBe('manual'); }); + +test('a company email stored with capitals still links the lowercase account on login (Audit F22)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['email' => 'gross@example.test', 'is_active' => true]); + $user->assignRole('customer'); + $company = Company::factory()->presseecho()->create(['email' => 'Gross@Example.test']); + + Volt::test('auth.login')->set('email', 'gross@example.test')->set('password', 'password')->call('login')->assertHasNoErrors(); + + expect(User::query()->findOrFail($user->id)->accessibleCompanyIds())->toContain($company->id); +}); diff --git a/tests/Feature/CustomerCompanyContextTest.php b/tests/Feature/CustomerCompanyContextTest.php index d7c48775..5ccb9295 100644 --- a/tests/Feature/CustomerCompanyContextTest.php +++ b/tests/Feature/CustomerCompanyContextTest.php @@ -1,11 +1,15 @@ call('startEditCompany') ->assertForbidden(); }); + +test('a deleted press kit contact disappears from the list and cannot be edited (Audit F04)', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + $company = Company::factory()->presseecho()->create(['owner_user_id' => $customer->id]); + $contact = Contact::factory()->for($company)->create([ + 'first_name' => 'Gelöschte', + 'last_name' => 'Kontaktperson', + 'portal' => $company->portal->value, + ]); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-kits.show', ['id' => $company->id]) + ->assertSee('Kontaktperson') + ->call('deleteContact', $contact->id) + ->assertDontSee('Kontaktperson'); + + expect(fn () => LivewireVolt::test('customer.press-kits.show', ['id' => $company->id]) + ->assertDontSee('Kontaktperson') + ->call('editContact', $contact->id)) + ->toThrow(ModelNotFoundException::class); +}); + +test('saving or deleting a press kit contact drops the cached newsroom page', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $company = Company::factory()->presseecho()->create(['is_active' => true]); + $customer->companies()->attach($company->id, ['role' => 'responsible']); + PressRelease::factory() + ->published() + ->forPortal(Portal::Presseecho) + ->for($company) + ->create(['language' => 'de', 'published_at' => now()->subDay()]); + + $url = 'https://presseecho.test/de/newsroom/'.$company->slug; + $cacheKey = fn (): string => webPageCacheKey('web.newsroom.presseecho-both.de.'.$company->slug.'.page.1'); + + $this->actingAs($customer); + + // Aufwärmen — die Kontakte liegen mit im gecachten Seitenpaket. + $this->get($url)->assertOk(); + expect(Cache::has($cacheKey()))->toBeTrue(); + + LivewireVolt::test('customer.press-kits.show', ['id' => $company->id]) + ->call('startCreateContact') + ->set('contactLastName', 'Neu') + ->set('contactEmail', 'neu@example.test') + ->call('saveContact') + ->assertHasNoErrors(); + + // Der Key trägt die Generation: nach einem Flush existiert der alte + // Eintrag unter dem neuen Key nicht mehr. + expect(Cache::has($cacheKey()))->toBeFalse(); + + $contact = Contact::query()->where('company_id', $company->id)->firstOrFail(); + + $this->get($url)->assertOk(); + expect(Cache::has($cacheKey()))->toBeTrue(); + + LivewireVolt::test('customer.press-kits.show', ['id' => $company->id]) + ->call('deleteContact', $contact->id); + + expect(Cache::has($cacheKey()))->toBeFalse(); +}); + +test('a failed logo write keeps the previous press kit logo and reports a validation error', function () { + /** @var TestCase $this */ + $disk = Storage::fake('public'); + $disk->put('company-logos/presseecho/7/alt.png', 'alt'); + $disk->put('company-logos/presseecho/7/alt-sq.png', 'alt-sq'); + + $customer = User::factory()->create(['is_active' => true]); + $company = Company::factory()->presseecho()->create([ + 'logo_path' => 'company-logos/presseecho/7/alt.png', + 'logo_variants' => ['sq' => 'company-logos/presseecho/7/alt-sq.png'], + ]); + $customer->companies()->attach($company->id, ['role' => 'responsible']); + + // Proxy auf den echten Fake: nur put() schlägt fehl (voller Datenträger, + // Rechteproblem), alles andere — auch Livewires Temp-Upload — läuft echt. + $failingDisk = Mockery::mock($disk); + $failingDisk->shouldReceive('put')->andReturn(false); + Storage::set('public', $failingDisk); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-kits.show', ['id' => $company->id]) + ->call('startEditCompany') + ->set('companyLogo', UploadedFile::fake()->image('neu.png', 400, 400)) + ->call('saveCompany') + ->assertHasErrors(['companyLogo']); + + $company->refresh(); + + expect($company->logo_path)->toBe('company-logos/presseecho/7/alt.png') + ->and($company->logo_variants)->toBe(['sq' => 'company-logos/presseecho/7/alt-sq.png']) + ->and($disk->exists('company-logos/presseecho/7/alt.png'))->toBeTrue() + ->and($disk->exists('company-logos/presseecho/7/alt-sq.png'))->toBeTrue(); +}); diff --git a/tests/Feature/GlobalScopeGuardTest.php b/tests/Feature/GlobalScopeGuardTest.php new file mode 100644 index 00000000..0f5305fe --- /dev/null +++ b/tests/Feature/GlobalScopeGuardTest.php @@ -0,0 +1,71 @@ +flatMap(fn (string $dir) => File::allFiles($dir)) + ->filter(fn ($file) => str_ends_with($file->getFilename(), '.php')) + ->filter(fn ($file) => str_contains($file->getContents(), 'withoutGlobalScopes()')) + ->map(fn ($file) => str_replace(base_path().'/', '', $file->getPathname())) + ->sort() + ->values() + ->all(); + + expect(array_values(array_diff($hits, $allowed)))->toBe([], 'Neue Stelle mit withoutGlobalScopes() ohne Argument – bitte withoutGlobalScope(PortalScope::class) verwenden oder die Ausnahme hier begründen.'); +}); diff --git a/tests/Feature/Legal/PressReleaseReportFlowTest.php b/tests/Feature/Legal/PressReleaseReportFlowTest.php index 8d1ddcaa..949c61d5 100644 --- a/tests/Feature/Legal/PressReleaseReportFlowTest.php +++ b/tests/Feature/Legal/PressReleaseReportFlowTest.php @@ -31,7 +31,11 @@ function publishedReleaseWithContact(string $contactEmail = 'presse@firma.exampl function detailUrlFor(PressRelease $pressRelease): string { - return 'https://presseecho.test/de/press-release/'.$pressRelease->slug; + // Die Zuordnung prüft den Host: Die URL muss zum Portal der PM gehören + // (F12) — die Factory würfelt das Portal. + $base = (string) config('domains.domains.'.$pressRelease->portal->value.'.url'); + + return $base.'/de/press-release/'.$pressRelease->slug; } test('the intake sends a magic link to the stored press contact, never to a form address', function () { diff --git a/tests/Feature/PressReleaseAttachmentsManagerTest.php b/tests/Feature/PressReleaseAttachmentsManagerTest.php index 9064fac9..2dfc86d1 100644 --- a/tests/Feature/PressReleaseAttachmentsManagerTest.php +++ b/tests/Feature/PressReleaseAttachmentsManagerTest.php @@ -256,3 +256,42 @@ test('attachments cannot be uploaded when PR is published', function () { expect($pr->attachments()->count())->toBe(0); }); + +test('a failed disk write is reported instead of returning file metadata', function () { + /** @var TestCase $this */ + ['pr' => $pr] = makeDraftWithOwner(); + + // Proxy auf den echten Fake: nur put() schlägt fehl (voller Datenträger, + // Rechteproblem — die Disks sind mit throw=false konfiguriert, F23). + $failingDisk = Mockery::mock(privateAttachmentDisk()); + $failingDisk->shouldReceive('put')->andReturn(false); + Storage::set('local', $failingDisk); + + $file = UploadedFile::fake()->create('factsheet.pdf', 200, 'application/pdf'); + + expect(fn () => app(PressReleaseAttachmentStorage::class)->store($file, $pr->id)) + ->toThrow(RuntimeException::class, 'Der Anhang konnte nicht gespeichert werden.'); +}); + +test('a failed disk write creates no attachment row', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner(); + + $this->actingAs($owner); + + $failingDisk = Mockery::mock(privateAttachmentDisk()); + $failingDisk->shouldReceive('put')->andReturn(false); + Storage::set('local', $failingDisk); + + $file = UploadedFile::fake()->create('factsheet.pdf', 200, 'application/pdf'); + + expect(fn () => LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id]) + ->set('newFile', $file) + ->set('newTitle', 'Factsheet Q1') + ->call('upload')) + ->toThrow(RuntimeException::class); + + // Kein Datensatz ohne Datei: Sonst stünde im Pressefach ein Anhang, der + // beim Abruf ins Leere läuft. + expect(PressReleaseAttachment::query()->where('press_release_id', $pr->id)->count())->toBe(0); +}); diff --git a/tests/Feature/PressReleaseClassificationJobTest.php b/tests/Feature/PressReleaseClassificationJobTest.php index 749c3c41..32e4046d 100644 --- a/tests/Feature/PressReleaseClassificationJobTest.php +++ b/tests/Feature/PressReleaseClassificationJobTest.php @@ -282,3 +282,53 @@ test('submitForReview dispatches the classification job onto the classification return $job->pressReleaseId === $pressRelease->id; }); }); + +test('a classification for content that changed during the analysis is discarded and re-queued (Audit F07)', function () { + Queue::fake(); + config()->set('scoring.classification.provider', 'openai'); + config()->set('services.openai.api_key', 'test-key'); + + $pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins', 'status' => PressReleaseStatus::Review->value]); + $hashAtDispatch = $pressRelease->content_hash; + + // Während der KI-Aufruf läuft, speichert ein Admin Version zwei. + Http::fake(function () use ($pressRelease) { + PressRelease::withoutGlobalScopes()->whereKey($pressRelease->id)->first()->update(['text' => 'Inhalt zwei']); + + return Http::response(['choices' => [['message' => ['content' => json_encode(['classification' => 'green', 'reasons' => []])]]]], 200); + }); + + (new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $hashAtDispatch))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + $fresh = $pressRelease->fresh(); + // Das alte Ergebnis darf Version zwei weder bewerten noch freigeben. + expect($fresh->classification)->toBeNull() + ->and($fresh->status)->toBe(PressReleaseStatus::Review); + + $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); + expect($audit->discarded_at)->not->toBeNull() + ->and($audit->content_hash)->toBe($hashAtDispatch); + + Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $pressRelease->id + && $job->route === true + && $job->contentHash === $fresh->content_hash); +}); + +test('a classification for unchanged content is stored with its content hash', function () { + fakeOpenAiClassification('green'); + + $pressRelease = PressRelease::factory()->create(['title' => 'Stabil', 'text' => 'Inhalt']); + + (new ClassifyPressRelease($pressRelease->id, route: false, contentHash: $pressRelease->content_hash))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); + expect($audit->content_hash)->toBe($pressRelease->content_hash) + ->and($audit->discarded_at)->toBeNull() + ->and($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green); +}); diff --git a/tests/Feature/PressReleaseContentScoreTest.php b/tests/Feature/PressReleaseContentScoreTest.php index ff7da680..754e8b33 100644 --- a/tests/Feature/PressReleaseContentScoreTest.php +++ b/tests/Feature/PressReleaseContentScoreTest.php @@ -149,3 +149,38 @@ test('rescoreIfScored dispatches only for an already scored press release', func Queue::assertPushed(ScorePressRelease::class, 1); }); + +test('a score for content that changed during the analysis is discarded and re-queued (Audit F07)', function () { + Queue::fake(); + config()->set('scoring.content_score.provider', 'openai'); + config()->set('services.openai.api_key', 'test-key'); + + $pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins']); + $hashAtDispatch = $pressRelease->content_hash; + + Http::fake(function () use ($pressRelease) { + PressRelease::withoutGlobalScopes()->whereKey($pressRelease->id)->first()->update(['text' => 'Inhalt zwei']); + + return Http::response(['choices' => [['message' => ['content' => json_encode(['score' => 90, 'breakdown' => ['pressestil' => 20]])]]]], 200); + }); + + (new ScorePressRelease($pressRelease->id, contentHash: $hashAtDispatch))->handle(app(ContentScoreManager::class)); + + $fresh = $pressRelease->fresh(); + expect($fresh->content_score)->toBeNull(); + + $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); + expect($audit->discarded_at)->not->toBeNull(); + + Queue::assertPushed(ScorePressRelease::class, fn (ScorePressRelease $job) => $job->pressReleaseId === $pressRelease->id + && $job->contentHash === $fresh->content_hash); +}); + +test('the score job has a timeout that fits the http budget and retry_after (Audit F17)', function () { + $job = new ScorePressRelease(1); + + // Drei Versuche à 30 s plus Verbindungsaufbau passen nicht in die 60 s des + // Workers; der Job trägt sein eigenes Budget, und retry_after liegt darüber. + expect($job->timeout)->toBeGreaterThanOrEqual(120) + ->and((int) config('queue.connections.database.retry_after'))->toBeGreaterThan($job->timeout); +}); diff --git a/tests/Feature/PressReleasePreviewTest.php b/tests/Feature/PressReleasePreviewTest.php index ba7824ab..2b4aefc5 100644 --- a/tests/Feature/PressReleasePreviewTest.php +++ b/tests/Feature/PressReleasePreviewTest.php @@ -234,7 +234,7 @@ test('invalid token returns 404', function () { ->assertSee('ungültig'); }); -test('preview link for soft deleted press release returns 404', function () { +test('preview link for permanently deleted press release returns 404', function () { /** @var TestCase $this */ $owner = User::factory()->create(); $pr = PressRelease::factory()->create(['user_id' => $owner->id]); @@ -247,6 +247,22 @@ test('preview link for soft deleted press release returns 404', function () { ->assertStatus(404); }); +test('preview link for soft deleted press release returns 404 (Audit F04)', function () { + /** @var TestCase $this */ + $owner = User::factory()->create(); + $pr = PressRelease::factory()->create(['user_id' => $owner->id, 'title' => 'Geloeschte Vorschau-Meldung']); + + $share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner); + + // Normale Löschung (Soft Delete) – der Vorschau-Token darf die Meldung + // nicht mehr laden, auch wenn die Zeile noch existiert. + $pr->delete(); + + $this->get(route('press-releases.preview', ['token' => $share['plain_token']])) + ->assertStatus(404) + ->assertDontSee('Geloeschte Vorschau-Meldung'); +}); + test('share panel offers a direct open button for single portal releases', function () { /** @var TestCase $this */ [$customer, $pr] = makePreviewCustomerWithPressRelease(Portal::Presseecho); diff --git a/tests/Feature/PressReleaseQuotaTest.php b/tests/Feature/PressReleaseQuotaTest.php index bc57e536..85a37c72 100644 --- a/tests/Feature/PressReleaseQuotaTest.php +++ b/tests/Feature/PressReleaseQuotaTest.php @@ -14,6 +14,7 @@ use App\Models\UserPaymentOption; use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\QuotaExceededException; use Database\Seeders\RolesAndPermissionsSeeder; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -236,3 +237,38 @@ test('monthly reset command zeroes the used counter', function () { expect(User::where('press_release_quota_used_this_month', '>', 0)->count())->toBe(0); expect($untouched->fresh()->press_release_quota_used_this_month)->toBe(0); }); + +test('quota consumption resets lazily when the calendar month changes, without the cron (Audit F18)', function () { + Carbon::setTestNow('2026-09-18 10:00:00'); + + $user = quotaTestSubscriber(planQuota: 3, used: 3); + // Verbrauch aus dem Vormonat – der Reset-Cron ist ausgefallen. + $user->forceFill(['press_release_quota_period' => '2026-08'])->save(); + + expect($user->fresh()->pressReleaseQuotaRemaining())->toBe(3); + + $pr = quotaTestPressRelease($user, 'review'); + app(PressReleaseService::class)->publish($pr); + + $fresh = $user->fresh(); + expect($fresh->press_release_quota_used_this_month)->toBe(1) + ->and($fresh->press_release_quota_period)->toBe('2026-09') + ->and($fresh->pressReleaseQuotaRemaining())->toBe(2); +}); + +test('the monthly reset is idempotent and leaves this month\'s consumption alone (Audit F18)', function () { + /** @var TestCase $this */ + Carbon::setTestNow('2026-09-01 00:05:00'); + + $thisMonth = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => '2026-09']); + $lastMonth = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => '2026-08']); + $unknown = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => null]); + + $this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful(); + $this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful(); + + expect($thisMonth->fresh()->press_release_quota_used_this_month)->toBe(2) + ->and($lastMonth->fresh()->press_release_quota_used_this_month)->toBe(0) + ->and($lastMonth->fresh()->press_release_quota_period)->toBe('2026-09') + ->and($unknown->fresh()->press_release_quota_used_this_month)->toBe(0); +}); diff --git a/tests/Feature/PressReleaseSchedulingTest.php b/tests/Feature/PressReleaseSchedulingTest.php index f9cebff7..c02a6f72 100644 --- a/tests/Feature/PressReleaseSchedulingTest.php +++ b/tests/Feature/PressReleaseSchedulingTest.php @@ -3,6 +3,8 @@ use App\Console\Commands\PublishScheduledPressReleases; use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseStatus; +use App\Jobs\ClassifyPressRelease; +use App\Models\KiAudit; use App\Models\PressRelease; use App\Models\PressReleaseStatusLog; use App\Services\PressRelease\PressReleaseService; @@ -10,6 +12,7 @@ use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Tests\TestCase; beforeEach(function (): void { @@ -244,3 +247,72 @@ test('Command publisht maximal --limit pro Lauf', function () { expect($publishedCount)->toBe(2); }); + +test('the scheduler reports failure when a due release cannot be published (Audit F20)', function () { + /** @var TestCase $this */ + Carbon::setTestNow('2026-06-01 12:00:00'); + + PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'scheduled_at' => '2026-06-01 11:55:00', + 'published_at' => null, + ]); + + $this->mock(PressReleaseService::class) + ->shouldReceive('publish') + ->once() + ->andThrow(new RuntimeException('Datenbank nicht erreichbar')); + + $this->artisan(PublishScheduledPressReleases::class)->assertFailed(); +}); + +test('the scheduler re-checks a due release whose classification predates the last edit instead of publishing it (Audit F07)', function () { + /** @var TestCase $this */ + Carbon::setTestNow('2026-06-01 12:00:00'); + Queue::fake(); + + $stale = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'scheduled_at' => '2026-06-01 11:55:00', + 'published_at' => null, + ]); + KiAudit::factory()->create([ + 'press_release_id' => $stale->id, + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'result' => 'green', + 'content_hash' => str_repeat('0', 64), + ]); + + $current = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'scheduled_at' => '2026-06-01 11:55:00', + 'published_at' => null, + ]); + KiAudit::factory()->create([ + 'press_release_id' => $current->id, + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'result' => 'green', + 'content_hash' => $current->content_hash, + ]); + + // Ohne Audit (Altbestand vor der Hash-Bindung) wird wie bisher veröffentlicht. + $legacy = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'scheduled_at' => '2026-06-01 11:55:00', + 'published_at' => null, + ]); + + Artisan::call(PublishScheduledPressReleases::class); + + expect($stale->fresh()->status)->toBe(PressReleaseStatus::Review) + ->and($current->fresh()->status)->toBe(PressReleaseStatus::Published) + ->and($legacy->fresh()->status)->toBe(PressReleaseStatus::Published); + + Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $stale->id + && $job->route === true + && $job->contentHash === $stale->fresh()->content_hash); +}); diff --git a/tests/Feature/Web/CompanyNewsroomCacheObserverTest.php b/tests/Feature/Web/CompanyNewsroomCacheObserverTest.php new file mode 100644 index 00000000..22a0f243 --- /dev/null +++ b/tests/Feature/Web/CompanyNewsroomCacheObserverTest.php @@ -0,0 +1,58 @@ +businessportal24()->create(); + $before = WebPageCache::generation(); + + $company->delete(); + + expect(WebPageCache::generation())->toBe($before + 1); +}); + +test('restoring a soft-deleted company flushes the public page caches', function () { + /** @var TestCase $this */ + $company = Company::factory()->businessportal24()->create(); + $company->delete(); + $before = WebPageCache::generation(); + + $company->restore(); + + expect(WebPageCache::generation())->toBe($before + 1); +}); + +test('changing is_active flushes the public page caches', function () { + /** @var TestCase $this */ + $company = Company::factory()->businessportal24()->create(['is_active' => true]); + $before = WebPageCache::generation(); + + $company->update(['is_active' => false]); + + expect(WebPageCache::generation())->toBe($before + 1); + + $company->update(['is_active' => true]); + + expect(WebPageCache::generation())->toBe($before + 2); +}); + +test('a change without public effect does not flush the page caches', function () { + /** @var TestCase $this */ + $company = Company::factory()->businessportal24()->create(); + $before = WebPageCache::generation(); + + // Telefonnummer und ein Save ohne Änderung: kein Grund, die gesamte + // öffentliche Seite in den Neuaufbau zu zwingen. + $company->update(['phone' => '+49 30 555 0']); + $company->touch(); + + expect(WebPageCache::generation())->toBe($before); +}); diff --git a/tests/Feature/Web/GoneTombstoneTest.php b/tests/Feature/Web/GoneTombstoneTest.php index 9cb46de4..f90495ef 100644 --- a/tests/Feature/Web/GoneTombstoneTest.php +++ b/tests/Feature/Web/GoneTombstoneTest.php @@ -165,9 +165,16 @@ test('a fully deleted newsroom still answers 404', function () { goneTestRelease($company); $url = 'https://businessportal24.test/de/newsroom/'.$company->slug; + // Erst aufwärmen: Die normale Löschung muss auch einen bereits + // gecachten Newsroom sofort aus der Auslieferung nehmen — als 404, + // nicht als Tombstone (F13). + $this->get($url)->assertOk(); + $company->delete(); - $this->get($url)->assertNotFound(); + $this->get($url) + ->assertNotFound() + ->assertDontSeeText('Dieser Newsroom wurde entfernt'); }); test('removing a newsroom leaves its press releases alone unless asked to cascade', function () { diff --git a/tests/Feature/Web/NewsroomPageTest.php b/tests/Feature/Web/NewsroomPageTest.php index 2ad945fd..4da1c355 100644 --- a/tests/Feature/Web/NewsroomPageTest.php +++ b/tests/Feature/Web/NewsroomPageTest.php @@ -179,3 +179,60 @@ test('legacy newsroom html urls redirect to the clean edition path', function () $this->get('https://businessportal24.test/de/newsroom/acme.html') ->assertRedirect('https://businessportal24.test/de/newsroom/acme'); }); + +test('soft-deleting a company takes a cached newsroom page down immediately', function () { + /** @var TestCase $this */ + $company = makeNewsroom('Aufgelöst GmbH'); + publishForCompany($company, 'Letzte Meldung vor der Löschung'); + $url = 'https://businessportal24.test/de/newsroom/'.$company->slug; + + // Erst aufwärmen: ohne warmen Seiten-Cache prüft der Test nur den + // Neuaufbau, nicht das Verhalten beim Cache-Treffer (F13). + $this->get($url)->assertOk(); + + $company->delete(); + + $this->get($url)->assertNotFound(); +}); + +test('deactivating a company takes a cached newsroom page down immediately', function () { + /** @var TestCase $this */ + $company = makeNewsroom('Ruhend AG'); + publishForCompany($company, 'Letzte Meldung vor der Deaktivierung'); + $url = 'https://businessportal24.test/de/newsroom/'.$company->slug; + + $this->get($url)->assertOk(); + + $company->update(['is_active' => false]); + + $this->get($url)->assertNotFound(); +}); + +test('a deactivated company beats a warm newsroom page cache even without a flush', function () { + /** @var TestCase $this */ + $company = makeNewsroom('Still deaktiviert GmbH'); + publishForCompany($company, 'Meldung im Cache'); + $url = 'https://businessportal24.test/de/newsroom/'.$company->slug; + + $this->get($url)->assertOk(); + + // Ohne Model-Events, also ohne Observer-Flush: So prüft der Test die + // Sichtbarkeitsprüfung VOR dem Cache-Treffer in NewsroomDetailPageData. + $company->forceFill(['is_active' => false])->saveQuietly(); + + $this->get($url)->assertNotFound(); +}); + +test('a soft-deleted company beats a warm newsroom page cache even without a flush', function () { + /** @var TestCase $this */ + $company = makeNewsroom('Still gelöscht GmbH'); + publishForCompany($company, 'Meldung im Cache'); + $url = 'https://businessportal24.test/de/newsroom/'.$company->slug; + + $this->get($url)->assertOk(); + + // Query-Builder statt delete(): keine Events, kein Flush. + Company::query()->withoutGlobalScopes()->whereKey($company->id)->update(['deleted_at' => now()]); + + $this->get($url)->assertNotFound(); +}); diff --git a/tests/Feature/Web/PressReleaseTrackingTest.php b/tests/Feature/Web/PressReleaseTrackingTest.php index d10b7fba..8247ea00 100644 --- a/tests/Feature/Web/PressReleaseTrackingTest.php +++ b/tests/Feature/Web/PressReleaseTrackingTest.php @@ -245,3 +245,23 @@ test('deleting a press release removes its tracked events', function () { expect(PressReleaseEvent::query()->count())->toBe(0); }); + +test('a second aggregation run is refused while another one holds the lock (Audit F19)', function () { + /** @var TestCase $this */ + $release = trackingRelease(['hits' => 0]); + $url = 'https://businessportal24.test/de/press-release/'.$release->slug; + + $this->get($url)->assertSuccessful(); + + $lock = Cache::lock(AggregatePressReleaseViews::LockKey, 60); + expect($lock->get())->toBeTrue(); + + // Manueller Lauf parallel zum Scheduler: nichts verbuchen, nicht abstürzen. + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(0); + + $lock->release(); + + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1); +}); diff --git a/tests/Feature/Web/ReleaseDetailTest.php b/tests/Feature/Web/ReleaseDetailTest.php index 8e95689b..f39b5d1d 100644 --- a/tests/Feature/Web/ReleaseDetailTest.php +++ b/tests/Feature/Web/ReleaseDetailTest.php @@ -481,3 +481,30 @@ test('a single recommendation list spans the full layout width', function () { ->assertSee('grid grid-cols-1 gap-10"', false) ->assertDontSee('grid grid-cols-1 gap-10 md:grid-cols-2', false); }); + +test('a deleted company contact is not shown as fallback press contact (Audit F04)', function () { + /** @var TestCase $this */ + [$category, $company] = detailCategoryAndCompany(Portal::Businessportal24); + + $release = PressRelease::factory() + ->published() + ->forPortal(Portal::Businessportal24) + ->for($category) + ->for($company) + ->create(['slug' => 'ohne-eigenen-kontakt', 'published_at' => now()->subDay()]); + + // Kein PM-Kontakt → die Seite fällt auf den Firmen-Kontakt zurück. Ein + // gelöschter Kontakt darf dabei nicht öffentlich erscheinen. + Contact::factory()->create([ + 'portal' => Portal::Businessportal24, + 'company_id' => $company->id, + 'first_name' => 'Ehemalige', + 'last_name' => 'Sprecherin', + 'email' => 'ehemalig@muster-energie.de', + ])->delete(); + + $this->get('https://businessportal24.test/de/press-release/'.$release->slug) + ->assertSuccessful() + ->assertDontSeeText('Ehemalige Sprecherin') + ->assertDontSee('ehemalig@muster-energie.de'); +}); diff --git a/tests/Feature/Web/ReportFormTest.php b/tests/Feature/Web/ReportFormTest.php index fcc8e7e0..a469fa40 100644 --- a/tests/Feature/Web/ReportFormTest.php +++ b/tests/Feature/Web/ReportFormTest.php @@ -62,7 +62,7 @@ test('a reported url matching a published release is linked to it', function () ->create(['published_at' => now()->subDay()]); $this->post('https://businessportal24.test/de/melden/rechtswidrig', [ - 'reported_url' => 'https://businessportal24.com/de/press-release/'.$release->slug, + 'reported_url' => 'https://businessportal24.test/de/press-release/'.$release->slug, 'requester_name' => 'Erika Musterfrau', 'requester_email' => 'erika@example.com', 'message' => 'Diese Mitteilung verletzt meine Markenrechte an mehreren Stellen.', @@ -72,6 +72,33 @@ test('a reported url matching a published release is linked to it', function () expect(LegalRequest::query()->sole()->press_release_id)->toBe($release->id); }); +test('a reported url on a foreign host or the other portal is not linked to a release', function () { + /** @var TestCase $this */ + Mail::fake(); + + $release = PressRelease::factory() + ->published() + ->forPortal(Portal::Businessportal24) + ->create(['published_at' => now()->subDay()]); + + // Fremde Domain mit passendem Pfad und die URL des anderen Portals: beide + // gehören nicht zu dieser PM — die Meldung bleibt ohne PM-Bezug (F12). + foreach (['https://example.com', 'https://presseecho.test'] as $base) { + $this->post('https://businessportal24.test/de/melden/rechtswidrig', [ + 'reported_url' => $base.'/de/press-release/'.$release->slug, + 'requester_name' => 'Erika Musterfrau', + 'requester_email' => 'erika@example.com', + 'message' => 'Diese Mitteilung verletzt meine Markenrechte an mehreren Stellen.', + 'truth_affirmed' => '1', + ])->assertSessionHas('report-status'); + } + + expect(LegalRequest::query()->count())->toBe(2) + ->and(LegalRequest::query()->whereNotNull('press_release_id')->count())->toBe(0) + // Ohne PM-Bezug entscheidet der Host des Formulars über das Portal. + ->and(LegalRequest::query()->pluck('portal')->unique()->all())->toBe(['businessportal24']); +}); + test('the illegal content report requires the truthfulness affirmation and a valid url', function () { /** @var TestCase $this */ Mail::fake(); diff --git a/tests/Feature/Web/ReportedUrlResolverTest.php b/tests/Feature/Web/ReportedUrlResolverTest.php new file mode 100644 index 00000000..99754fe1 --- /dev/null +++ b/tests/Feature/Web/ReportedUrlResolverTest.php @@ -0,0 +1,88 @@ +published() + ->forPortal($portal) + ->for(Company::factory()->state(['portal' => $portal->value])) + ->create(array_merge([ + 'slug' => $slug, + 'language' => $language, + 'published_at' => now()->subDay(), + ], $attributes)); +} + +test('the same slug on both portals resolves to the release of the reported host', function () { + /** @var TestCase $this */ + $presseecho = resolverRelease(Portal::Presseecho, 'gleicher-slug'); + $businessportal = resolverRelease(Portal::Businessportal24, 'gleicher-slug'); + $resolver = app(ReportedUrlResolver::class); + + expect($resolver->resolve('https://presseecho.test/de/press-release/gleicher-slug')?->id)->toBe($presseecho->id) + ->and($resolver->resolve('https://businessportal24.test/de/press-release/gleicher-slug')?->id)->toBe($businessportal->id); +}); + +test('the same slug in both editions resolves to the release of the reported edition', function () { + /** @var TestCase $this */ + $german = resolverRelease(Portal::Presseecho, 'zwei-sprachen', 'de'); + $english = resolverRelease(Portal::Presseecho, 'zwei-sprachen', 'en'); + $resolver = app(ReportedUrlResolver::class); + + expect($resolver->resolve('https://presseecho.test/en/press-release/zwei-sprachen')?->id)->toBe($english->id) + ->and($resolver->resolve('https://presseecho.test/de/press-release/zwei-sprachen')?->id)->toBe($german->id) + // Ohne Ausgabe im Pfad gilt die Standard-Ausgabe (SetEdition). + ->and($resolver->resolve('https://presseecho.test/press-release/zwei-sprachen')?->id)->toBe($german->id); +}); + +test('a foreign host with a matching path resolves to nothing', function () { + /** @var TestCase $this */ + resolverRelease(Portal::Presseecho, 'fremder-host'); + $resolver = app(ReportedUrlResolver::class); + + expect($resolver->resolve('https://example.com/de/press-release/fremder-host'))->toBeNull() + ->and($resolver->resolve('https://presseecho.test.evil.example/de/press-release/fremder-host'))->toBeNull() + // Der Hub liefert keine Meldungen aus, er leitet auf das Portal um. + ->and($resolver->resolve('https://pressekonto.test/de/press-release/fremder-host'))->toBeNull() + ->and($resolver->resolve('kein-url'))->toBeNull(); +}); + +test('a www host and an url-encoded slug still resolve', function () { + /** @var TestCase $this */ + $release = resolverRelease(Portal::Businessportal24, 'kaffee-kuchen'); + $resolver = app(ReportedUrlResolver::class); + + expect($resolver->resolve('https://www.businessportal24.test/de/press-release/kaffee%2Dkuchen?utm_source=x#top')?->id)->toBe($release->id); +}); + +test('deleted, scheduled and unpublished releases resolve to nothing', function () { + /** @var TestCase $this */ + resolverRelease(Portal::Presseecho, 'geloescht')->delete(); + resolverRelease(Portal::Presseecho, 'terminiert', 'de', ['published_at' => now()->addDay()]); + PressRelease::factory()->forPortal(Portal::Presseecho)->create(['slug' => 'entwurf', 'language' => 'de']); + $resolver = app(ReportedUrlResolver::class); + + expect($resolver->resolve('https://presseecho.test/de/press-release/geloescht'))->toBeNull() + ->and($resolver->resolve('https://presseecho.test/de/press-release/terminiert'))->toBeNull() + ->and($resolver->resolve('https://presseecho.test/de/press-release/entwurf'))->toBeNull(); +}); + +test('a portal-wide copy loses against the canonical release with the same slug', function () { + /** @var TestCase $this */ + $canonical = resolverRelease(Portal::Presseecho, 'doppelt'); + $copy = resolverRelease(Portal::Both, 'doppelt', 'de', ['canonical_press_release_id' => $canonical->id]); + + expect($copy->id)->toBeGreaterThan($canonical->id) + ->and(app(ReportedUrlResolver::class)->resolve('https://presseecho.test/de/press-release/doppelt')?->id)->toBe($canonical->id); +});