Die 4x identische Zugriffs-Closure (owner_user_id == me OR whereHas('users',
me)) in CompanyController (index/show), PressReleaseController::
findOwnedCompany und CustomerCompanyContext::accessibleCompanyQuery durch
einen Company::scopeAccessibleBy() ersetzt; das Scope-Verhalten
(withoutGlobalScopes) bleibt beim Aufrufer.
Bewusst unangetastet: User::accessibleCompanyIds() (relationenbasiert MIT
globalen Scopes - anderes Scope-Verhalten) und PressReleaseImageController
(prueft PR-Autorschaft user_id, nicht Firmenzugriff).
Tests: neuer Scope-Test; volle Suite gruen (801 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
8.4 KiB
PHP
218 lines
8.4 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Api\V1;
|
||
|
||
use App\Enums\PressReleaseStatus;
|
||
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\Company;
|
||
use App\Models\PressRelease;
|
||
use App\Services\PressRelease\BlacklistViolationException;
|
||
use App\Services\PressRelease\BookingRequiredException;
|
||
use App\Services\PressRelease\PressReleaseService;
|
||
use App\Services\PressRelease\QuotaExceededException;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||
use Illuminate\Http\Response;
|
||
use Illuminate\Support\Str;
|
||
|
||
class PressReleaseController extends Controller
|
||
{
|
||
public function index(Request $request): AnonymousResourceCollection
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||
|
||
$pressReleases = PressRelease::withoutGlobalScopes()
|
||
->where('user_id', $request->user()->id)
|
||
->with(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
|
||
->latest()
|
||
->paginate(min((int) $request->query('per_page', 25), 100));
|
||
|
||
return PressReleaseResource::collection($pressReleases);
|
||
}
|
||
|
||
public function store(StorePressReleaseRequest $request): JsonResponse
|
||
{
|
||
$validated = $request->validated();
|
||
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
|
||
abort_unless($company !== null, 403);
|
||
|
||
$pressRelease = PressRelease::withoutGlobalScopes()->create([
|
||
...$validated,
|
||
'uuid' => (string) Str::uuid(),
|
||
'user_id' => $request->user()->id,
|
||
'portal' => $company->portal->value,
|
||
'slug' => $this->uniqueSlug(
|
||
Str::slug($validated['title']),
|
||
$company->portal->value,
|
||
$validated['language'],
|
||
),
|
||
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
|
||
// `review` erfolgt ausschließlich über die explizite submit-Route,
|
||
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
|
||
'status' => PressReleaseStatus::Draft->value,
|
||
]);
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||
)->response()->setStatusCode(201);
|
||
}
|
||
|
||
public function show(Request $request, int $pressRelease): PressReleaseResource
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||
);
|
||
}
|
||
|
||
public function update(UpdatePressReleaseRequest $request, int $pressRelease): PressReleaseResource|JsonResponse
|
||
{
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
|
||
return response()->json([
|
||
'message' => 'Only draft or rejected press releases may be edited.',
|
||
], 409);
|
||
}
|
||
|
||
$validated = $request->validated();
|
||
$company = isset($validated['company_id'])
|
||
? $this->findOwnedCompany((int) $validated['company_id'], $request)
|
||
: $this->findOwnedCompany((int) $pressRelease->company_id, $request);
|
||
abort_unless($company !== null, 403);
|
||
|
||
$language = $validated['language'] ?? $pressRelease->language;
|
||
$title = $validated['title'] ?? $pressRelease->title;
|
||
$portal = $company->portal->value;
|
||
|
||
$pressRelease->fill([
|
||
...$validated,
|
||
'portal' => $portal,
|
||
]);
|
||
|
||
if (
|
||
array_key_exists('title', $validated)
|
||
|| array_key_exists('language', $validated)
|
||
|| array_key_exists('company_id', $validated)
|
||
) {
|
||
$pressRelease->slug = $this->uniqueSlug(Str::slug($title), $portal, $language, $pressRelease->id);
|
||
}
|
||
|
||
$pressRelease->save();
|
||
|
||
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen.
|
||
if ($pressRelease->wasChanged(['title', 'text'])) {
|
||
$service = app(PressReleaseService::class);
|
||
$service->reclassifyIfClassified($pressRelease);
|
||
$service->rescoreIfScored($pressRelease);
|
||
}
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Reicht eine PM zur Prüfung ein – der einzige API-Weg nach `review`.
|
||
*
|
||
* Kapselt denselben Funnel wie das Web-Formular: Blacklist-Hard-Filter,
|
||
* Statuswechsel, Status-Log und Quota werden über den
|
||
* `PressReleaseService` garantiert durchlaufen. `published` bleibt über die
|
||
* API unerreichbar.
|
||
*/
|
||
public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
|
||
return response()->json([
|
||
'message' => 'Only draft or rejected press releases may be submitted for review.',
|
||
], 409);
|
||
}
|
||
|
||
try {
|
||
$service->submitForReview($pressRelease);
|
||
} catch (BookingRequiredException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 402);
|
||
} catch (QuotaExceededException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 422);
|
||
} catch (BlacklistViolationException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 422);
|
||
}
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->fresh()->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||
);
|
||
}
|
||
|
||
public function destroy(Request $request, int $pressRelease): JsonResponse|Response
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
if ($pressRelease->status->value === 'published') {
|
||
return response()->json([
|
||
'message' => 'Published press releases cannot be deleted via API.',
|
||
], 409);
|
||
}
|
||
|
||
$pressRelease->delete();
|
||
|
||
return response()->noContent();
|
||
}
|
||
|
||
private function findOwnedCompany(int $companyId, Request $request): ?Company
|
||
{
|
||
return Company::withoutGlobalScopes()
|
||
->whereKey($companyId)
|
||
->accessibleBy($request->user())
|
||
->first();
|
||
}
|
||
|
||
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||
{
|
||
return PressRelease::withoutGlobalScopes()
|
||
->whereKey($pressReleaseId)
|
||
->where('user_id', $request->user()->id)
|
||
->first();
|
||
}
|
||
|
||
private function uniqueSlug(string $baseSlug, string $portal, string $language, ?int $excludeId = null): string
|
||
{
|
||
$slug = $baseSlug !== '' ? $baseSlug : Str::random(8);
|
||
$candidate = $slug;
|
||
$suffix = 2;
|
||
|
||
while (
|
||
PressRelease::withoutGlobalScopes()
|
||
->where('portal', $portal)
|
||
->where('language', $language)
|
||
->where('slug', $candidate)
|
||
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
|
||
->exists()
|
||
) {
|
||
$candidate = "{$slug}-{$suffix}";
|
||
$suffix++;
|
||
}
|
||
|
||
return $candidate;
|
||
}
|
||
}
|