From 4fc46b02bf5f5b490fd45ac256b3a1c662a1082f Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 19 Jun 2026 10:27:59 +0000 Subject: [PATCH] Optimierung 3.2: API-PR-Ownership-Scope + zentrale Eager-Loads - PressRelease::scopeOwnedByApiUser() (bewusst eng: nur user_id-Autorschaft, anders als das breitere scopeAccessibleBy) ersetzt die byte-gleiche findOwnedPressRelease-Query in PressReleaseController + ImageController. - apiResourceRelations()-Helfer ersetzt den 5x identischen Eager-Load-Set (company/category.translations/images) in Index/Show/Create/Update/Reclassify. Bewusst NICHT umgesetzt: Slug-Reimplementierung (uniqueSlug) durch HasUniqueSlug ersetzen - der API-Fallback Str::random(8) weicht vom Trait-slugFallback() ab (Verhaltensaenderung bei leer-slugifizierten Titeln). Tests: neuer Scope-Test; volle Suite gruen (802 passed, 1 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Api/V1/PressReleaseController.php | 23 ++++++++++++++----- .../Api/V1/PressReleaseImageController.php | 2 +- app/Models/PressRelease.php | 10 ++++++++ tests/Feature/PressReleaseAccessScopeTest.php | 12 ++++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/Api/V1/PressReleaseController.php b/app/Http/Controllers/Api/V1/PressReleaseController.php index b8dd0df0..279d508e 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseController.php @@ -27,7 +27,7 @@ class PressReleaseController extends Controller $pressReleases = PressRelease::withoutGlobalScopes() ->where('user_id', $request->user()->id) - ->with(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images']) + ->with($this->apiResourceRelations()) ->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status)) ->latest() ->paginate(min((int) $request->query('per_page', 25), 100)); @@ -58,7 +58,7 @@ class PressReleaseController extends Controller ]); return PressReleaseResource::make( - $pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images']) + $pressRelease->load($this->apiResourceRelations()) )->response()->setStatusCode(201); } @@ -69,7 +69,7 @@ class PressReleaseController extends Controller abort_unless($pressRelease !== null, 403); return PressReleaseResource::make( - $pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images']) + $pressRelease->load($this->apiResourceRelations()) ); } @@ -117,7 +117,7 @@ class PressReleaseController extends Controller } return PressReleaseResource::make( - $pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images']) + $pressRelease->load($this->apiResourceRelations()) ); } @@ -158,7 +158,7 @@ class PressReleaseController extends Controller } return PressReleaseResource::make( - $pressRelease->fresh()->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images']) + $pressRelease->fresh()->load($this->apiResourceRelations()) ); } @@ -187,11 +187,22 @@ class PressReleaseController extends Controller ->first(); } + /** + * Relationen für die API-Resource-Ausgabe. Zentral, damit Index, Show, + * Create, Update und Reclassify denselben Satz laden. + * + * @return list + */ + private function apiResourceRelations(): array + { + return ['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images']; + } + private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease { return PressRelease::withoutGlobalScopes() ->whereKey($pressReleaseId) - ->where('user_id', $request->user()->id) + ->ownedByApiUser($request->user()) ->first(); } diff --git a/app/Http/Controllers/Api/V1/PressReleaseImageController.php b/app/Http/Controllers/Api/V1/PressReleaseImageController.php index f15fbb4f..a16bee8d 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseImageController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseImageController.php @@ -106,7 +106,7 @@ class PressReleaseImageController extends Controller { return PressRelease::withoutGlobalScopes() ->whereKey($pressReleaseId) - ->where('user_id', $request->user()->id) + ->ownedByApiUser($request->user()) ->first(); } diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index d0972f13..c7d832f4 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -206,6 +206,16 @@ class PressRelease extends Model }); } + /** + * Bewusst enger als {@see scopeAccessibleBy()}: Der API-Zugriff meint nur + * die eigene Autorschaft (user_id), nicht den breiteren Firmenzugriff. + * Single Source of Truth für die API-Controller. + */ + public function scopeOwnedByApiUser(Builder $query, User $user): Builder + { + return $query->where('user_id', $user->id); + } + /** * Display-ready text. Returns sanitized HTML for Phase-7+ PMs and *

/
-wrapped legacy plain text for older imports. diff --git a/tests/Feature/PressReleaseAccessScopeTest.php b/tests/Feature/PressReleaseAccessScopeTest.php index 571b57fb..76d248d8 100644 --- a/tests/Feature/PressReleaseAccessScopeTest.php +++ b/tests/Feature/PressReleaseAccessScopeTest.php @@ -22,6 +22,18 @@ test('accessibleBy includes a users own authored press release', function () { expect($ids)->toContain($pr->id); }); +test('ownedByApiUser matches only the authoring user, not company access', function () { + $author = User::factory()->create(); + $companyMate = User::factory()->create(); + $company = Company::factory()->create(); + $company->users()->attach($companyMate->id, ['role' => 'member']); + + $pr = PressRelease::factory()->create(['user_id' => $author->id, 'company_id' => $company->id]); + + expect(PressRelease::withoutGlobalScopes()->ownedByApiUser($author)->pluck('id'))->toContain($pr->id) + ->and(PressRelease::withoutGlobalScopes()->ownedByApiUser($companyMate)->pluck('id'))->not->toContain($pr->id); +}); + test('accessibleBy includes press releases of an assigned company', function () { $user = User::factory()->create(); $company = Company::factory()->presseecho()->create();