web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und Medienpfade sowie die PressRelease-Helfer schreiben jetzt withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein. Der Vorschau-Test prüfte bisher forceDelete statt delete. F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER. Neuer Index contacts_email_idx. F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt- Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache vorher nicht und traf den Fehler nie. F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst (ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung gewinnt die Erstquelle. F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren Vertrag. F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht als verlorenes Logo. Block 4 — KI-Prüfung und Scheduler F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen (ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum aktuellen Text passt, sondern plant eine Prüfung mit Routing ein. F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period). Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der Reset ist idempotent und lässt den laufenden Monat in Ruhe. F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen Fehler-Exit-Code, damit der Failure-Hook greift. F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt. F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
301bca3a90
commit
72432fdb82
73 changed files with 1746 additions and 170 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
|
|
|
|||
|
|
@ -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'))]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ namespace App\Http\Controllers\Web;
|
|||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\LegalRequestVerificationMethod;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\LegalRequestReceived;
|
||||
use App\Mail\LegalRequestSubmitted;
|
||||
|
|
@ -16,6 +15,7 @@ use App\Models\PressRelease;
|
|||
use App\Scopes\PortalScope;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Services\PressRelease\AnonymizationService;
|
||||
use App\Services\Web\ReportedUrlResolver;
|
||||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -36,6 +36,8 @@ class PressReleaseReportController extends Controller
|
|||
{
|
||||
private const SESSION_KEY = 'pm_report.verified';
|
||||
|
||||
public function __construct(private readonly ReportedUrlResolver $reportedUrls) {}
|
||||
|
||||
/** @var array<string, LegalRequestType> */
|
||||
public const CONCERNS = [
|
||||
'correction' => LegalRequestType::Correction,
|
||||
|
|
@ -63,7 +65,7 @@ class PressReleaseReportController extends Controller
|
|||
$this->ensureNotRateLimited($request);
|
||||
RateLimiter::hit($this->throttleKey($request), 3600);
|
||||
|
||||
$pressRelease = $this->matchPublishedRelease($validated['reported_url']);
|
||||
$pressRelease = $this->reportedUrls->resolve($validated['reported_url']);
|
||||
|
||||
if (! $pressRelease) {
|
||||
throw ValidationException::withMessages([
|
||||
|
|
@ -322,19 +324,6 @@ class PressReleaseReportController extends Controller
|
|||
];
|
||||
}
|
||||
|
||||
private function matchPublishedRelease(string $url): ?PressRelease
|
||||
{
|
||||
if (! preg_match('#/press-release/([^/?\#]+)#', $url, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->where('slug', urldecode($matches[1]))
|
||||
->where('status', PressReleaseStatus::Published)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* „kontakt@firma.de" → „k***@firma.de" — die Adresse selbst wird nie
|
||||
* angezeigt und nie aus dem Formular übernommen.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
50
app/Observers/CompanyPublicCacheObserver.php
Normal file
50
app/Observers/CompanyPublicCacheObserver.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Support\WebCaches;
|
||||
|
||||
/**
|
||||
* Verwirft die öffentlichen Seiten-Caches, sobald sich die Sichtbarkeit einer
|
||||
* Firma ändert: Löschung, Wiederherstellung, Wechsel von `is_active`.
|
||||
*
|
||||
* Am Model statt im Admin-Formular: Löschen und Deaktivieren passieren auch
|
||||
* aus Importern, Zusammenführungen und Konsolen-Befehlen — jeder Weg soll
|
||||
* denselben Flush bekommen wie der 410-Weg im CompanyRemovalService. Sonst
|
||||
* bleibt ein gecachter Newsroom bis zu eine Stunde erreichbar (F13).
|
||||
*/
|
||||
class CompanyPublicCacheObserver
|
||||
{
|
||||
public function updated(Company $company): void
|
||||
{
|
||||
// Nur Sichtbarkeitswechsel: Ein Flush verwirft ALLE Seiten-Caches und
|
||||
// zwingt die gesamte öffentliche Seite in den Neuaufbau — das darf
|
||||
// eine korrigierte Telefonnummer nicht auslösen.
|
||||
if (! $company->wasChanged('is_active')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Der 410-Weg setzt `is_active` zusammen mit `gone_at` und verwirft
|
||||
// die Caches selbst, einmal am Ende — auch nach einer Kaskade über
|
||||
// alle Meldungen. Ein zweiter Flush hier wäre ein weiterer Stampede.
|
||||
if ($company->wasChanged('gone_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
WebCaches::forgetPublicPages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Feuert für Soft Delete und forceDelete gleichermaßen.
|
||||
*/
|
||||
public function deleted(Company $company): void
|
||||
{
|
||||
WebCaches::forgetPublicPages();
|
||||
}
|
||||
|
||||
public function restored(Company $company): void
|
||||
{
|
||||
WebCaches::forgetPublicPages();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Models\Company;
|
|||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Support\EmailQuery;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
|
@ -155,15 +156,14 @@ class ContactAccessService
|
|||
}
|
||||
|
||||
/**
|
||||
* Hinterlegte Pressekontakte (mit Firma) zu einer E-Mail – case-insensitive,
|
||||
* portalübergreifend (kein PortalScope).
|
||||
* Hinterlegte Pressekontakte (mit Firma) zu einer E-Mail – case-insensitive
|
||||
* über den Index (EmailQuery), portalübergreifend (kein PortalScope).
|
||||
*
|
||||
* @return Collection<int, Contact>
|
||||
*/
|
||||
private function contactsFor(string $email): Collection
|
||||
{
|
||||
return Contact::withoutGlobalScope(PortalScope::class)
|
||||
->whereRaw('LOWER(email) = ?', [$email])
|
||||
return EmailQuery::whereEmail(Contact::withoutGlobalScope(PortalScope::class), $email)
|
||||
->whereNotNull('company_id')
|
||||
->get();
|
||||
}
|
||||
|
|
@ -176,8 +176,7 @@ class ContactAccessService
|
|||
*/
|
||||
private function companiesFor(string $email): Collection
|
||||
{
|
||||
return Company::withoutGlobalScope(PortalScope::class)
|
||||
->whereRaw('LOWER(email) = ?', [$email])
|
||||
return EmailQuery::whereEmail(Company::withoutGlobalScope(PortalScope::class), $email)
|
||||
->get();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
92
app/Services/Web/ReportedUrlResolver.php
Normal file
92
app/Services/Web/ReportedUrlResolver.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Web;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Http\Middleware\SetEdition;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
|
||||
/**
|
||||
* Ordnet eine gemeldete URL (Meldeverfahren) der Pressemitteilung zu, die
|
||||
* unter dieser Adresse tatsächlich ausgeliefert wird.
|
||||
*
|
||||
* Der Slug allein reicht nicht: Der Unique-Index der Meldungen ist
|
||||
* (portal, language, slug) — derselbe Slug kann auf beiden Portalen und in
|
||||
* beiden Ausgaben existieren. Deshalb Host → Portal, erstes Pfadsegment →
|
||||
* Ausgabe, dann erst der Slug. Fremde Hosts ergeben bewusst keinen Treffer;
|
||||
* der Hub ebenfalls, er leitet Meldungs-URLs nur auf das Portal um (F12).
|
||||
*/
|
||||
class ReportedUrlResolver
|
||||
{
|
||||
public function resolve(string $url): ?PressRelease
|
||||
{
|
||||
$parts = parse_url(trim($url));
|
||||
|
||||
if ($parts === false || ! isset($parts['host'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$portal = $this->portalForHost($parts['host']);
|
||||
|
||||
if ($portal === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Detail-Route: /{edition}/press-release/{slug}. Ohne Ausgabe im Pfad
|
||||
// gilt wie in SetEdition die Standard-Ausgabe.
|
||||
$editions = implode('|', SetEdition::EDITIONS);
|
||||
|
||||
if (! preg_match('#^/(?:('.$editions.')/)?press-release/([^/]+)/?$#', $parts['path'] ?? '', $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$language = $matches[1] !== '' ? $matches[1] : SetEdition::DEFAULT_EDITION;
|
||||
$slug = rawurldecode($matches[2]);
|
||||
|
||||
return PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->forPortalValues([$portal->value, Portal::Both->value])
|
||||
->forLanguage($language)
|
||||
->where('slug', $slug)
|
||||
->visibleInArchive()
|
||||
// Doppelveröffentlichung: beide Fassungen tragen denselben Slug —
|
||||
// die Erstquelle gewinnt (wie in RedirectPortalOnlyPagesFromHub).
|
||||
->orderByRaw('canonical_press_release_id is null desc')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal hinter dem Host laut config/domains.php — oder null für jeden
|
||||
* Host, der dort nicht als Portal steht (Hub, fremde Domains, Subdomains
|
||||
* mit vorangestelltem Portalnamen).
|
||||
*/
|
||||
private function portalForHost(string $host): ?Portal
|
||||
{
|
||||
$host = strtolower($host);
|
||||
|
||||
if (str_starts_with($host, 'www.')) {
|
||||
$host = substr($host, 4);
|
||||
}
|
||||
|
||||
foreach ((array) config('domains.domains', []) as $key => $domain) {
|
||||
$portal = Portal::tryFrom((string) $key);
|
||||
|
||||
if ($portal === null || $portal === Portal::Both) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$configuredHosts = array_filter([
|
||||
strtolower((string) ($domain['domain_name'] ?? '')),
|
||||
strtolower((string) (parse_url((string) ($domain['url'] ?? ''), PHP_URL_HOST) ?: '')),
|
||||
]);
|
||||
|
||||
if (in_array($host, $configuredHosts, true)) {
|
||||
return $portal;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
41
app/Support/EmailQuery.php
Normal file
41
app/Support/EmailQuery.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Contracts\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Case-insensitiver E-Mail-Vergleich, der den B-Tree-Index nutzt (Audit F22).
|
||||
*
|
||||
* `LOWER(email) = ?` schließt jeden Index aus – auf `companies` (110.000
|
||||
* Zeilen) ein Vollscan bei jedem Login. Die Spalten `users.email`,
|
||||
* `companies.email` und `contacts.email` tragen die Kollation
|
||||
* `utf8mb4_unicode_ci`, MySQL vergleicht also ohnehin ohne Groß-/Klein-
|
||||
* schreibung; der direkte Vergleich reicht und trifft den Index.
|
||||
*
|
||||
* SQLite (Testsuite) vergleicht `=` binär – dort bleibt LOWER(), damit die
|
||||
* Tests dieselbe Semantik prüfen. Der Aufrufer übergibt die Adresse bereits
|
||||
* kleingeschrieben und getrimmt.
|
||||
*/
|
||||
final class EmailQuery
|
||||
{
|
||||
/**
|
||||
* @template TBuilder of Builder|QueryBuilder
|
||||
*
|
||||
* @param TBuilder $query
|
||||
* @return TBuilder
|
||||
*/
|
||||
public static function whereEmail(Builder|QueryBuilder $query, string $email, string $column = 'email'): Builder|QueryBuilder
|
||||
{
|
||||
$connection = $query->getConnection();
|
||||
$driver = $connection instanceof Connection ? $connection->getDriverName() : 'unknown';
|
||||
|
||||
if ($driver === 'mysql' || $driver === 'mariadb') {
|
||||
return $query->where($column, $email);
|
||||
}
|
||||
|
||||
return $query->whereRaw('LOWER('.$column.') = ?', [$email]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* B-Tree-Index auf `contacts.email` (Audit F22).
|
||||
*
|
||||
* `contacts` hatte auf `email` nur den FULLTEXT für die Admin-Suche – ein
|
||||
* `WHERE email = ?` (Login-Sync, Magic-Link-Zugang, Bounce-Import) lief als
|
||||
* Vollscan über 21.000 Zeilen. Wie bei `companies_email_idx`: `ADD INDEX`
|
||||
* läuft trotz vorhandenem FULLTEXT `INPLACE`, bei 6 MB eine Sache von
|
||||
* Sekunden.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('contacts', function (Blueprint $table) {
|
||||
$table->index('email', 'contacts_email_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('contacts', function (Blueprint $table) {
|
||||
$table->dropIndex('contacts_email_idx');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* KI-Ergebnisse an die geprüfte Inhaltsversion binden (Audit F07).
|
||||
*
|
||||
* `content_hash` ist die Prüfsumme (ContentFingerprint) des Textes, den der
|
||||
* Treiber tatsächlich gesehen hat. `discarded_at` markiert Ergebnisse, die
|
||||
* beim Speichern nicht mehr zum aktuellen Inhalt passten und deshalb
|
||||
* verworfen wurden – sie bleiben als Audit-Spur erhalten. Kleine Tabelle,
|
||||
* kein Volltext-Index.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ki_audits', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('ki_audits', 'content_hash')) {
|
||||
$table->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,
|
||||
]));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Kalendermonat des Kontingent-Verbrauchs (Audit F18, E5).
|
||||
*
|
||||
* `press_release_quota_period` (YYYY-MM) sagt, für welchen Monat
|
||||
* `press_release_quota_used_this_month` gilt. Weicht er vom laufenden Monat
|
||||
* ab, zählt der Verbrauch als null – beim Lesen wie beim Verbrauchen. Der
|
||||
* Reset-Cron am Monatsersten wird damit zum Aufräumer statt zur einzigen
|
||||
* Wahrheit: ein verpasster Lauf sperrt niemanden mehr aus, ein doppelter
|
||||
* löscht keinen Verbrauch des laufenden Monats.
|
||||
*
|
||||
* Kosten: `users` (25 MB) trägt einen Volltext-Index, ADD COLUMN läuft
|
||||
* deshalb als COPY – wenige Sekunden, nachts einspielen.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('users', 'press_release_quota_period')) {
|
||||
$table->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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
106
tests/Feature/Admin/CompanyLogoReplacementTest.php
Normal file
106
tests/Feature/Admin/CompanyLogoReplacementTest.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use App\Services\Image\ImageService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Logo-Tausch: erst die neue Datei speichern, dann die DB-Referenz wechseln,
|
||||
* dann erst das alte Logo löschen. Ein Storage-Fehler darf weder Erfolg
|
||||
* vortäuschen noch das bisherige Logo kosten (F23).
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->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();
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
|
|
@ -109,3 +110,97 @@ test('api user cannot access another users press release', function () {
|
|||
$this->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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -376,3 +380,105 @@ test('member customer cannot manage press kit company master data', function ()
|
|||
->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();
|
||||
});
|
||||
|
|
|
|||
71
tests/Feature/GlobalScopeGuardTest.php
Normal file
71
tests/Feature/GlobalScopeGuardTest.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* `withoutGlobalScopes()` ohne Argument entfernt neben dem PortalScope auch
|
||||
* den Soft-Delete-Filter (Audit F04): gelöschte Meldungen, Firmen und
|
||||
* Kontakte tauchten in API, Vorschau, Medien, Kundenpanel und auf der
|
||||
* öffentlichen Meldungsseite wieder auf. Öffentliche, Kunden- und API-Pfade
|
||||
* schreiben deshalb `withoutGlobalScope(PortalScope::class)`.
|
||||
*
|
||||
* Die Liste unten sind die bewusst verbliebenen Stellen: Admin-Verwaltung
|
||||
* (darf Gelöschtes sehen), Importer und Befehle, Slug-Eindeutigkeit (muss
|
||||
* gelöschte Zeilen einschließen) und die Newsletter-Anmeldung. Wer eine
|
||||
* neue Stelle braucht, trägt sie hier ein und begründet das im Commit.
|
||||
*/
|
||||
test('withoutGlobalScopes() without arguments stays confined to admin, import and slug code (Audit F04)', function () {
|
||||
$allowed = [
|
||||
'app/Console/Commands/CheckMailDomains.php',
|
||||
'app/Console/Commands/ImportNewsletterBounces.php',
|
||||
'app/Console/Commands/ListCompanyMerges.php',
|
||||
'app/Console/Commands/MigrateLegacyMedia.php',
|
||||
'app/Console/Commands/PurgeExpiredPressReleaseDrafts.php',
|
||||
'app/Console/Commands/RetireAgencyCompanyType.php',
|
||||
'app/Console/Commands/SeedLegacyApiTestdata.php',
|
||||
'app/Console/Commands/SyncCompanyLogos.php',
|
||||
'app/Console/Commands/SyncPressReleaseImages.php',
|
||||
'app/Http/Controllers/Admin/DashboardController.php',
|
||||
'app/Http/Controllers/Api/V1/NewsletterSubscriptionController.php',
|
||||
'app/Http/Controllers/Web/LegacyMediaRedirectController.php',
|
||||
'app/Livewire/Concerns/HandlesAdminPressReleaseActions.php',
|
||||
'app/Models/Company.php',
|
||||
'app/Models/Concerns/HasUniqueSlug.php',
|
||||
'app/Services/Admin/AdminPerformanceCache.php',
|
||||
'app/Services/Company/CompanyMergeService.php',
|
||||
'app/Services/Import/CompanyImporter.php',
|
||||
'app/Services/Import/ContactImporter.php',
|
||||
'app/Services/Import/PressReleaseImporter.php',
|
||||
'app/Services/Mail/SubmitterContactReport.php',
|
||||
'app/Services/PressRelease/CanonicalAssigner.php',
|
||||
'app/Services/PressRelease/CorrectionService.php',
|
||||
'app/Services/PressRelease/ProofPdfRenderer.php',
|
||||
'app/Services/SearchConsole/IndexStatusReport.php',
|
||||
'resources/views/livewire/admin/categories/edit.blade.php',
|
||||
'resources/views/livewire/admin/categories/index.blade.php',
|
||||
'resources/views/livewire/admin/companies/index.blade.php',
|
||||
'resources/views/livewire/admin/companies/show.blade.php',
|
||||
'resources/views/livewire/admin/contacts/create.blade.php',
|
||||
'resources/views/livewire/admin/contacts/edit.blade.php',
|
||||
'resources/views/livewire/admin/contacts/index.blade.php',
|
||||
'resources/views/livewire/admin/press-releases/create.blade.php',
|
||||
'resources/views/livewire/admin/press-releases/edit.blade.php',
|
||||
'resources/views/livewire/admin/press-releases/index.blade.php',
|
||||
'resources/views/livewire/admin/press-releases/show.blade.php',
|
||||
'resources/views/livewire/admin/reports/submitter-contacts.blade.php',
|
||||
'resources/views/livewire/admin/users.blade.php',
|
||||
'resources/views/livewire/admin/users/create.blade.php',
|
||||
'resources/views/livewire/admin/users/edit.blade.php',
|
||||
];
|
||||
|
||||
$hits = collect([app_path(), resource_path('views'), base_path('routes')])
|
||||
->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.');
|
||||
});
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
58
tests/Feature/Web/CompanyNewsroomCacheObserverTest.php
Normal file
58
tests/Feature/Web/CompanyNewsroomCacheObserverTest.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Support\WebPageCache;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Öffentliche Seiten-Caches müssen jeden Sichtbarkeitswechsel einer Firma
|
||||
* mitbekommen — nicht nur den 410-Weg über den CompanyRemovalService (F13).
|
||||
* Der Flush hängt deshalb am Model, nicht am Admin-Formular.
|
||||
*/
|
||||
test('soft-deleting a company flushes the public page caches', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = Company::factory()->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);
|
||||
});
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
88
tests/Feature/Web/ReportedUrlResolverTest.php
Normal file
88
tests/Feature/Web/ReportedUrlResolverTest.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\Web\ReportedUrlResolver;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Eine gemeldete URL gehört genau zu einer Pressemitteilung: Der Slug ist nur
|
||||
* innerhalb von Portal UND Sprache eindeutig (F12). Host → Portal, erstes
|
||||
* Pfadsegment → Ausgabe, dann erst der Slug.
|
||||
*/
|
||||
function resolverRelease(Portal $portal, string $slug, string $language = 'de', array $attributes = []): PressRelease
|
||||
{
|
||||
return PressRelease::factory()
|
||||
->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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue