presseportale/app/Http/Controllers/Api/V1/PressReleaseController.php
Kevin Adametz 4419d9ff43 Phase 9 Block 1: Gelb-Routing Direkt-Live, Slot-Verbrauch bei Veroeffentlichung, Submit-Gate
9A — Gelb geht direkt live (Entscheidung 12.06.2026):
- routeByClassification(): Gelb durchlaeuft denselben Auto-Publish-Pfad
  wie Gruen (autoPublishApproved); nur Rot wird abgelehnt
- Scheduler publiziert faellige gelbe + gruene PMs; unklassifizierte
  bleiben als Fallback in der manuellen Queue

9B — Slot-Verbrauch bei Veroeffentlichung (Decision-Update 3.2):
- Increment aus submitForReview() entfernt; publish() und
  changeStatusFromAdmin() zaehlen idempotent beim ersten
  published-Uebergang (Pruefung ueber Status-Logs); Rot kostet nichts
- Submit-Guard: Einreichen erfordert freien Slot
  (QuotaExceededException, API 422)

9C — Submit-Gate vorbereitet (Decision-Update 5.1):
- User::hasActiveBooking()-Stub hinter config/billing.php
  (enforce_booking, Default aus); Tarif-Modul ersetzt nur den Rumpf
- Einreichungs-Modal zeigt ohne Buchung einen Buchungs-Hinweis;
  Server-Guard (BookingRequiredException), API antwortet 402
- Fix: Customer-Create legte PMs bei "Zur Pruefung senden" direkt mit
  Status review an (vorbei an Blacklist/Quota/KI/Status-Log) — laeuft
  jetzt immer ueber submitForReview()

Suite: 451 passed, 4 skipped (9 neue Tests). Pint clean.
Plan: docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md (Block 2 nach Review-Stopp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:47:06 +00:00

221 lines
8.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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)
->where(function ($query) use ($request): void {
$query->where('owner_user_id', $request->user()->id)
->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id));
})
->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;
}
}