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>
221 lines
8.6 KiB
PHP
221 lines
8.6 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)
|
||
->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;
|
||
}
|
||
}
|